Compare commits

...
Author SHA1 Message Date
retoor bde3819510 Update. 2025-06-01 00:24:45 +02:00
retoor 097889ba3f Make database asnyc. 2025-05-31 23:30:41 +02:00
retoor 4854d40508 Update. 2025-05-31 13:52:04 +02:00
retoor 7dd3133475 Merge pull request 'Add URL embedding functionality with metadata extraction and responsive design' (#48) from BordedDev/snek:feat/url-embedding into main
Reviewed-on: #48
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-30 19:53:22 +02:00
retoor 24dfa39f91 Update chat input. 2025-05-30 19:32:18 +02:00
BordedDev 7ec65f7c12 Cleaned up imports 2025-05-30 02:14:45 +02:00
BordedDev 4f8edef42b Add URL embedding functionality with metadata extraction and responsive design 2025-05-30 02:13:36 +02:00
retoor 7818410d55 Upddated interval. 2025-05-29 02:42:31 +02:00
retoor 1762191b03 Update. 2025-05-28 21:49:30 +02:00
retoor 2df92e809e Added iinput mode. 2025-05-28 13:40:12 +02:00
retoor 59a8d32e40 Added iinput mode. 2025-05-28 13:36:43 +02:00
retoor c3b3963760 Fix cross typing. 2025-05-28 12:31:17 +02:00
retoor a0cd39e3bc Fix cross typing. 2025-05-28 12:29:01 +02:00
retoor e48b2258e0 Made live typing default. 2025-05-28 11:38:16 +02:00
retoor 35aaf8824f Fixed directory does not exist bug. 2025-05-28 10:40:05 +02:00
retoor 76c69ca3ec Is finalized 2025-05-27 23:14:06 +02:00
retoor 9994225911 Update sort. 2025-05-27 22:22:32 +02:00
retoor 03f699e448 Updated online users. 2025-05-27 22:16:41 +02:00
retoor 2fd01a5ab7 Stars update. 2025-05-27 14:28:07 +02:00
retoor 96629113f1 Most recent users. 2025-05-27 14:03:01 +02:00
retoor 973afa0cc2 Scrolled to bottom fix. 2025-05-27 12:33:04 +02:00
retoor 9bc55e771a Scrolled to bottom fix. 2025-05-27 12:30:42 +02:00
retoor 40a292d05e Scrolled to bottom fix. 2025-05-27 12:21:40 +02:00
retoor bf723db2cc Scrolled to bottom fix. 2025-05-27 12:16:47 +02:00
retoor 46052172b2 Scrolled to bottom fix. 2025-05-27 12:14:02 +02:00
retoor 4f777f0003 Scrolled to bottom fix. 2025-05-27 12:12:03 +02:00
retoor b5e1ba72d0 Scrolled to bottom fix. 2025-05-27 12:10:26 +02:00
retoor df120098f9 Scrolled to bottom fix. 2025-05-27 12:08:53 +02:00
retoor 1c1d578db7 Fixed mentions. 2025-05-27 12:06:35 +02:00
retoor 69352fe0b5 Fixed mentions. 2025-05-27 11:45:23 +02:00
retoor fb3980dad0 Removed last-message. 2025-05-27 11:03:25 +02:00
retoor 6c21a1e619 Async load users. 2025-05-27 10:57:18 +02:00
retoor 112c0dc70a Update channel message. 2025-05-27 10:50:09 +02:00
retoor 538a9ce25d Scrolled to bottom fix. 2025-05-27 10:43:44 +02:00
retoor cdc3d10df5 Moved method. 2025-05-27 10:33:00 +02:00
retoor 1b150e3e64 Moved method. 2025-05-27 10:33:00 +02:00
retoor 27dccc324a Moved method. 2025-05-27 10:33:00 +02:00
retoor 1bb68ab33b Moved method. 2025-05-27 10:33:00 +02:00
retoor 3c6ea15d47 Update render to include user info. 2025-05-27 10:33:00 +02:00
retoor 36e663e1ed added animal view. 2025-05-27 10:33:00 +02:00
20 changed files with 530 additions and 2948 deletions
-2558
View File
File diff suppressed because one or more lines are too long
-289
View File
@@ -1,289 +0,0 @@
import http.server
import socketserver
import json
import os
import subprocess
from urllib.parse import parse_qs, urlparse
import mimetypes
import html
# --- Theme selection (choose one: "light1", "light2", "dark1", "dark2") ---
THEME = "light1" # Change this to "light2", "dark1", or "dark2" as desired
THEMES = {
"light1": """
body { font-family: Arial, sans-serif; background: #f6f8fa; margin: 0; padding: 0; color: #222; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #ccc; }
.commit { margin: 1em 0; padding: 1em; background: #fff; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.07); }
.hash { color: #555; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f1f1f1; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #0366d6; }
a:hover { text-decoration: underline; }
""",
"light2": """
body { font-family: 'Segoe UI', sans-serif; background: #fdf6e3; margin: 0; padding: 0; color: #333; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #e1c699; color: #b58900; }
.commit { margin: 1em 0; padding: 1em; background: #fffbe6; border-radius: 6px; box-shadow: 0 1px 3px rgba(200,180,100,0.08); }
.hash { color: #b58900; font-family: monospace; }
.diff { white-space: pre-wrap; background: #f5e9c9; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #b58900; }
a:hover { text-decoration: underline; color: #cb4b16; }
""",
"dark1": """
body { font-family: Arial, sans-serif; background: #181a1b; margin: 0; padding: 0; color: #eaeaea; }
.container { max-width: 960px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 1px solid #333; color: #8ab4f8; }
.commit { margin: 1em 0; padding: 1em; background: #23272b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.18); }
.hash { color: #8ab4f8; font-family: monospace; }
.diff { white-space: pre-wrap; background: #23272b; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #8ab4f8; }
a:hover { text-decoration: underline; color: #bb86fc; }
""",
"dark2": """
body { font-family: 'Fira Sans', sans-serif; background: #121212; margin: 0; padding: 0; color: #d0d0d0; }
.container { max-width: 900px; margin: auto; padding: 2em; }
.date-header { font-size: 1.2em; margin-top: 2em; border-bottom: 2px solid #444; color: #ffb86c; }
.commit { margin: 1em 0; padding: 1em; background: #22223b; border-radius: 6px; box-shadow: 0 1px 3px rgba(0,0,0,0.22); }
.hash { color: #ffb86c; font-family: monospace; }
.diff { white-space: pre-wrap; background: #282a36; padding: 1em; border-radius: 4px; overflow-x: auto; }
ul { list-style: none; padding-left: 0; }
li { margin: 0.3em 0; }
a { text-decoration: none; color: #ffb86c; }
a:hover { text-decoration: underline; color: #8be9fd; }
""",
}
def HTML_TEMPLATE(content, theme=THEME):
return f"""
<!DOCTYPE html>
<html lang=\"en\">
<head>
<meta charset=\"UTF-8\">
<title>Git Log Viewer</title>
<link rel=\"stylesheet\" href=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github.min.css\">
<style>
{THEMES[theme]}
</style>
<script src=\"https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js\"></script>
<script>hljs.highlightAll();</script>
</head>
<body>
<div class=\"container\">
{content}
</div>
</body>
</html>
"""
REPO_ROOT = os.path.abspath(".")
LOG_FILE = os.path.join(REPO_ROOT, "gitlog.jsonl")
PORT = 8481
def format_diff_to_html(diff_text: str) -> str:
lines = diff_text.strip().splitlines()
html_lines = ['<div style="font-family: monospace; white-space: pre;">']
while not lines[3].startswith('diff'):
lines.pop(3)
lines.insert(3, "")
for line in lines:
escaped = html.escape(line)
if "//" in line:
continue
if "#" in line:
continue
if "/*" in line:
continue
if "*/" in line:
continue
if line.startswith('+++') or line.startswith('---'):
html_lines.append(f'<div style="color: #0000aa;">{escaped}</div>')
elif line.startswith('@@'):
html_lines.append(f'<div style="color: #005cc5;">{escaped}</div>')
elif line.startswith('+'):
html_lines.append(f'<div style="color: #22863a;">{escaped}</div>')
elif line.startswith('-'):
html_lines.append(f'<div style="color: #b31d28;">{escaped}</div>')
elif line.startswith('\\'):
html_lines.append(f'<div style="color: #6a737d;">{escaped}</div>')
else:
html_lines.append(f'<div>{escaped}</div>')
html_lines.append('</div>')
return '\n'.join(html_lines)
def parse_logs():
logs = []
if not os.path.exists(LOG_FILE):
return []
lines = []
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
if line.strip():
if line.strip() not in lines:
lines.append(line.strip())
logs.append(json.loads(line.strip()))
return logs
def group_by_date(logs):
grouped = {}
for entry in logs:
date = entry["date"]
grouped.setdefault(date, []).append(entry)
return dict(sorted(grouped.items(), reverse=True))
def get_git_diff(commit_hash):
try:
result = subprocess.run(
["git", "-C", REPO_ROOT, "show", commit_hash, "--no-color"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
check=True,
)
return result.stdout
except subprocess.CalledProcessError as e:
return f"Error retrieving diff: {e.stderr}"
def list_directory(path, base_url="/browse?path="):
try:
entries = os.listdir(path)
except OSError:
return "<div>Cannot access directory.</div>"
entries.sort()
content = "<ul>"
# Parent directory link
parent = os.path.dirname(path)
if os.path.abspath(path) != REPO_ROOT:
parent_rel = os.path.relpath(parent, REPO_ROOT)
content += f"<li><a href='{base_url}{html.escape(parent_rel)}'>.. (parent directory)</a></li>"
for entry in entries:
full_path = os.path.join(path, entry)
rel_path = os.path.relpath(full_path, REPO_ROOT)
if os.path.isdir(full_path):
content += f"<li>📁 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}/</a></li>"
else:
content += f"<li>📄 <a href='{base_url}{html.escape(rel_path)}'>{html.escape(entry)}</a></li>"
content += "</ul>"
return content
def read_file_content(path):
try:
with open(path, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
return f"Error reading file: {e}"
def get_language_class(filename):
ext = os.path.splitext(filename)[1].lower()
return {
'.py': 'python',
'.js': 'javascript',
'.html': 'html',
'.css': 'css',
'.json': 'json',
'.sh': 'bash',
'.md': 'markdown',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'cpp',
'.java': 'java',
'.rb': 'ruby',
'.go': 'go',
'.php': 'php',
'.rs': 'rust',
'.ts': 'typescript',
'.xml': 'xml',
'.yml': 'yaml',
'.yaml': 'yaml',
}.get(ext, '')
class GitLogHandler(http.server.SimpleHTTPRequestHandler):
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/":
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
logs = parse_logs()
grouped = group_by_date(logs)
content = "<p><a href='/browse'>Browse Files</a></p>"
for date, commits in grouped.items():
content += f"<div class='date-header'>{date}</div>"
for c in commits:
commit_link = f"/diff?hash={c['commit']}"
content += f"""
<div class='commit'>
<div><strong>{c['line'].splitlines()[0]}</strong></div>
<div class='hash'><a href='{commit_link}'>{c['commit']}</a></div>
</div>
"""
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif parsed.path == "/diff":
qs = parse_qs(parsed.query)
commit = qs.get("hash", [""])[0]
diff = format_diff_to_html(get_git_diff(html.escape(commit)))
diff_html = f"<h2>Commit: {commit}</h2><div class='diff'>{diff}</div><p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(diff_html).encode("utf-8"))
elif parsed.path == "/browse":
qs = parse_qs(parsed.query)
rel_path = qs.get("path", [""])[0]
abs_path = os.path.abspath(os.path.join(REPO_ROOT, rel_path))
# Security: prevent escaping the repo root
if not abs_path.startswith(REPO_ROOT):
self.send_error(403, "Forbidden")
return
if os.path.isdir(abs_path):
content = f"<h2>Browsing: /{html.escape(rel_path)}</h2>"
content += list_directory(abs_path)
content += "<p><a href='/'>← Back to commits</a></p>"
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
elif os.path.isfile(abs_path):
file_content = read_file_content(abs_path)
lang_class = get_language_class(abs_path)
content = f"<h2>File: /{html.escape(rel_path)}</h2>"
content += (
f"<pre style='background:#f1f1f1; padding:1em; border-radius:4px; overflow-x:auto;'>"
f"<code class='{lang_class}'>{html.escape(file_content)}</code></pre>"
)
content += "<p><a href='{}'>← Back to directory</a></p>".format(
f"/browse?path={html.escape(os.path.dirname(rel_path))}"
)
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(HTML_TEMPLATE(content).encode("utf-8"))
else:
self.send_error(404, "Not found")
else:
self.send_error(404)
if __name__ == "__main__":
while True:
try:
with socketserver.TCPServer(("", PORT), GitLogHandler) as httpd:
print(f"Serving at http://localhost:{PORT}")
httpd.serve_forever()
break
except Exception as ex:
print(ex)
PORT += 1
+1
View File
@@ -7,6 +7,7 @@ class ChannelMessageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str)
message = ModelField(name="message", required=True, kind=str)
html = ModelField(name="html", required=False, kind=str)
is_final = ModelField(name="is_final", required=True, kind=bool, value=True)
def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds())
+4
View File
@@ -67,6 +67,10 @@ class ChannelService(BaseService):
await self.services.channel_member.create_dm(channel["uid"], user1, user2)
return channel
async def get_recent_users(self, channel_uid):
async for user in self.query("SELECT user.uid, user.username,user.color,user.last_ping,user.nick FROM channel_member INNER JOIN user ON user.uid = channel_member.user_uid WHERE channel_uid=:channel_uid AND user.last_ping >= datetime('now', '-3 minutes') ORDER BY last_ping DESC LIMIT 30", {"channel_uid": channel_uid}):
yield user
async def get_users(self, channel_uid):
async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid,
+13 -2
View File
@@ -4,12 +4,13 @@ from snek.system.service import BaseService
class ChannelMessageService(BaseService):
mapper_name = "channel_message"
async def create(self, channel_uid, user_uid, message):
async def create(self, channel_uid, user_uid, message, is_final=True):
model = await self.new()
model["channel_uid"] = channel_uid
model["user_uid"] = user_uid
model["message"] = message
model['is_final'] = is_final
context = {}
@@ -51,7 +52,17 @@ class ChannelMessageService(BaseService):
}
async def save(self, model):
context = model.record
context = {}
context.update(model.record)
user = await self.app.services.user.get(model['user_uid'])
context.update(
{
"user_uid": user["uid"],
"username": user["username"],
"user_nick": user["nick"],
"color": user["color"],
}
)
template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context)
return await super().save(model)
+29 -2
View File
@@ -4,12 +4,38 @@ from snek.system.service import BaseService
class ChatService(BaseService):
async def send(self, user_uid, channel_uid, message):
async def finalize(self, message_uid):
channel_message = await self.services.channel_message.get(uid=message_uid)
channel_message["is_final"] = True
await self.services.channel_message.save(channel_message)
user = await self.services.user.get(uid=channel_message["user_uid"])
channel = await self.services.channel.get(uid=channel_message["channel_uid"])
channel["last_message_on"] = now()
await self.services.channel.save(channel)
await self.services.socket.broadcast(
channel['uid'],
{
"message": channel_message["message"],
"html": channel_message["html"],
"user_uid": user['uid'],
"color": user["color"],
"channel_uid": channel['uid'],
"created_at": channel_message["created_at"],
"updated_at": channel_message["updated_at"],
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": channel_message["is_final"],
},
)
async def send(self, user_uid, channel_uid, message, is_final=True):
channel = await self.services.channel.get(uid=channel_uid)
if not channel:
raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create(
channel_uid, user_uid, message
channel_uid, user_uid, message,is_final
)
channel_message_uid = channel_message["uid"]
@@ -30,6 +56,7 @@ class ChatService(BaseService):
"username": user["username"],
"uid": channel_message["uid"],
"user_nick": user["nick"],
"is_final": is_final,
},
)
await self.app.create_task(
+1 -1
View File
@@ -45,7 +45,7 @@ async def start_ssh_server(app,host,port):
logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key"
host_key_path.parent.mkdir(exist_ok=True)
host_key_path.parent.mkdir(exist_ok=True, parents=True)
try:
if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}")
+11 -5
View File
@@ -160,11 +160,15 @@ export class App extends EventHandler {
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
async set_typing(channel_uid) {
_debug = false
async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid;
}
async ping(...args) {
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) {
if (this.is_pinging) return false;
this.is_pinging = true;
await this.rpc.ping(...args);
@@ -202,8 +206,10 @@ export class App extends EventHandler {
this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data);
});
this.ws.addEventListener("event", (data) => {
console.info("aaaa");
this.ws.addEventListener("data", (data) => {
if(this._debug){
console.debug(data)
}
});
this.rpc.getUser(null).then((user) => {
me.user = user;
+42 -7
View File
@@ -47,7 +47,6 @@ main {
}
header {
background-color: #000000;
grid-area: header;
padding: 10px 20px;
display: flex;
@@ -102,13 +101,11 @@ h2 {
flex: 1;
display: flex;
flex-direction: column;
background-color: #000000;
overflow: hidden;
}
.chat-header {
padding: 10px 20px;
background-color: #000000;
user-select: none;
}
@@ -139,7 +136,6 @@ footer {
-ms-overflow-style: none;
padding: 10px;
height: 10px;
background: #000000;
}
.chat-messages {
@@ -244,15 +240,14 @@ footer {
.chat-input {
padding: 15px;
background-color: #000000;
display: flex;
align-items: center;
}
input[type="text"], .chat-input textarea {
flex: 1;
background-color: #000000;
color: white;
background: none;
border: none;
padding: 10px;
border-radius: 5px;
@@ -357,7 +352,6 @@ a {
.sidebar {
width: 250px;
background-color: #000000;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
@@ -550,3 +544,44 @@ dialog .dialog-button.secondary:hover {
}
.embed-url-link {
display: flex;
flex-direction: column;
}
.embed-url-link img,
.embed-url-link video,
.embed-url-link iframe,
.embed-url-link div {
width: auto;
height: auto;
max-width: 100%;
max-height: 400px;
object-fit: contain;
border-radius: 12px 12px 0 0;
}
.embed-url-link .page-site {
font-size: 0.9em;
color: #aaa;
margin-bottom: 5px;
}
.embed-url-link .page-name {
font-size: 1.2em;
color: #f05a28;
margin-bottom: 5px;
}
.embed-url-link .page-description {
font-size: 1em;
color: #e6e6e6;
margin-bottom: 10px;
}
.embed-url-link .page-link {
font-size: 0.9em;
color: #f05a28;
text-decoration: none;
margin-top: 10px;
}
+59 -23
View File
@@ -4,13 +4,23 @@ class ChatInputComponent extends HTMLElement {
autoCompletions = {
"example 1": () => {},
"example 2": () => {},
};
}
hiddenCompletions = {
"/starsRender": () => {
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
}
}
users = []
textarea = null
_value = ""
lastUpdateEvent = null
previousValue = ""
lastChange = null
changed = false
constructor() {
super();
this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea");
this._value = "";
this.value = this.getAttribute("value") || "";
this.previousValue = this.value;
this.lastChange = new Date();
@@ -25,12 +35,15 @@ class ChatInputComponent extends HTMLElement {
this._value = value || "";
this.textarea.value = this._value;
}
get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
}
resolveAutoComplete() {
let count = 0;
let value = null;
Object.keys(this.autoCompletions).forEach((key) => {
if (key.startsWith(this.value)) {
Object.keys(this.allAutoCompletions).forEach((key) => {
if (key.startsWith(this.value.split(" ")[0])) {
count++;
value = key;
}
@@ -56,7 +69,7 @@ class ChatInputComponent extends HTMLElement {
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_]+)/g;
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
@@ -70,13 +83,23 @@ class ChatInputComponent extends HTMLElement {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
const distance = this.levenshteinDistance(mention.toLowerCase(), author.toLowerCase());
const lowerAuthor = author.toLowerCase();
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
distance += 10
}
if (distance < minDistance) {
minDistance = distance;
closestAuthor = author;
}
});
return { mention, closestAuthor, distance: minDistance };
@@ -118,11 +141,9 @@ levenshteinDistance(a, b) {
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
console.info(matches)
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
console.info(closestAuthor)
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
@@ -141,10 +162,12 @@ levenshteinDistance(a, b) {
const me = this;
this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 3;
parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel");
this.users = await app.rpc.getUsers(this.channelUid)
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
})
this.messageUid = null;
this.classList.add("chat-input");
@@ -178,9 +201,10 @@ levenshteinDistance(a, b) {
this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") {
e.preventDefault();
let autoCompletion = this.resolveAutoComplete();
autoCompletion = this.resolveAutoComplete();
if (autoCompletion) {
e.target.value = autoCompletion;
this.value = autoCompletion;
@@ -196,18 +220,18 @@ levenshteinDistance(a, b) {
if (!message) {
return;
}
let autoCompletion = this.autoCompletions[message];
if (autoCompletion) {
this.value = "";
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
autoCompletion();
return;
}
this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = "";
this.previousValue = "";
this.messageUid = null;
@@ -239,7 +263,7 @@ levenshteinDistance(a, b) {
e.detail.files.forEach((file) => {
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
});
app.rpc.sendMessage(this.channelUid, message);
app.rpc.sendMessage(this.channelUid, message,true);
});
setTimeout(()=>{
this.focus();
@@ -250,13 +274,25 @@ levenshteinDistance(a, b) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000;
}
isSubsequence(s, t) {
let i = 0, j = 0;
while (i < s.length && j < t.length) {
if (s[i] === t[j]) {
i++;
}
j++;
}
return i === s.length;
}
newMessage() {
if (!this.messageUid) {
this.messageUid = "?";
}
this.sendMessage(this.channelUid, this.value).then((uid) => {
this.value = this.replaceMentionsWithAuthors(this.value);
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
if (this.liveType) {
this.messageUid = uid;
}
@@ -326,11 +362,11 @@ levenshteinDistance(a, b) {
}
}
async sendMessage(channelUid, value) {
async sendMessage(channelUid, value,is_final) {
if (!value.trim()) {
return null;
}
return await app.rpc.sendMessage(channelUid, value);
return await app.rpc.sendMessage(channelUid, value,is_final);
}
}
+35 -3
View File
@@ -18,9 +18,39 @@ class MessageList extends HTMLElement {
this.items = [];
}
connectedCallback() {
const messagesContainer = this
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
})
}
isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
isScrolledToBottom() {
return this.isElementVisible(this.querySelector(".message-list-bottom"));
}
scrollToBottom(force) {
console.info("Scrolling down")
// if (force) {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
@@ -29,7 +59,6 @@ class MessageList extends HTMLElement {
this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView();
},200)
// }
}
updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
@@ -37,12 +66,15 @@ class MessageList extends HTMLElement {
if (!messageDiv) {
return;
}
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
}
triggerGlow(uid,color) {
app.starField.glowColor(color)
+2 -1
View File
@@ -25,7 +25,8 @@
border-radius: 50%;
background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out;
}
z-index: -1;
}
@keyframes twinkle {
0%, 100% { opacity: 0.8; transform: scale(1); }
+8 -1
View File
@@ -17,6 +17,8 @@ export class Socket extends EventHandler {
shouldReconnect = true;
_debug = false;
get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN;
}
@@ -112,7 +114,12 @@ export class Socket extends EventHandler {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
return me.call(functionName, ...args);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args);
};
},
},
+16 -8
View File
@@ -1,6 +1,6 @@
DEFAULT_LIMIT = 30
import typing
import asyncio
from snek.system.model import BaseModel
@@ -19,6 +19,13 @@ class BaseMapper:
def db(self):
return self.app.db
@property
def loop(self):
return asyncio.get_event_loop()
async def run_in_executor(self, func, *args, **kwargs):
return await self.loop.run_in_executor(None, lambda: func(*args, **kwargs))
async def new(self):
return self.model_class(mapper=self, app=self.app)
@@ -29,7 +36,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid:
kwargs["uid"] = uid
record = self.table.find_one(**kwargs)
record = await self.run_in_executor(self.table.find_one,**kwargs)
if not record:
return None
record = dict(record)
@@ -40,31 +48,31 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs):
return self.table.exists(**kwargs)
return await self.run_in_executor(self.table.exists,**kwargs)
async def count(self, **kwargs) -> int:
return self.table.count(**kwargs)
return await self.run_in_executor(self.table.count, **kwargs)
async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update()
return self.table.upsert(model.record, ["uid"])
return await self.run_in_executor(self.table.upsert, model.record, ["uid"])
async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_limit
for record in self.table.find(**kwargs):
for record in await self.run_in_executor(self.table.find, **kwargs):
model = await self.new()
for key, value in record.items():
model[key] = value
yield model
async def query(self, sql, *args):
for record in self.db.query(sql, *args):
for record in await self.run_in_executor(self.db.query,sql, *args):
yield dict(record)
async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.")
return self.table.delete(**kwargs)
return await self.run_in_executor(self.table.delete, **kwargs)
+156 -4
View File
@@ -1,9 +1,11 @@
import re
from urllib.parse import urlparse, parse_qs
from types import SimpleNamespace
import mimetypes
import re
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlparse, parse_qs
from app.cache import time_cache
import emoji
import requests
from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension
@@ -231,6 +233,153 @@ def linkify_https(text):
return set_link_target_blank(str(soup))
@time_cache(timeout=60*60)
def get_url_content(url):
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
return response.text
except Exception as e:
print(f"Error fetching {url}: {e}")
return None
def embed_url(text):
soup = BeautifulSoup(text, "html.parser")
attachments = {}
for element in soup.find_all("a"):
if "href" in element.attrs and element.attrs["href"].startswith("http"):
page_url = urlparse(element.attrs["href"])
page = get_url_content(element.attrs["href"])
if page:
parsed_page = BeautifulSoup(page, "html.parser")
head_info = parsed_page.find("head")
if head_info:
def get_element_options(
elem=None, meta=None, ograph=None, twitter=None
):
if twitter:
tw_tag = head_info.find(
"meta", attrs={"name": "twitter:" + twitter}
) or head_info.find(
"meta", attrs={"property": "twitter:" + twitter}
)
if tw_tag:
return tw_tag.get("content", tw_tag.get("value", None))
if ograph:
og_tag = head_info.find(
"meta", attrs={"property": "og:" + ograph}
) or head_info.find("meta", attrs={"name": "og:" + ograph})
if og_tag:
return og_tag.get("content", og_tag.get("value", None))
if meta:
meta_tag = head_info.find(
"meta", attrs={"name": meta}
) or head_info.find("meta", attrs={"property": meta})
if meta_tag:
return meta_tag.get(
"content", meta_tag.get("value", None)
)
if elem:
elem_tag = head_info.find(elem)
if elem_tag:
return elem_tag.text
return None
original_link_name = element.attrs["href"]
if original_link_name in attachments:
continue
page_name = (
get_element_options("title", "title", "title", "title")
or page_url.netloc
)
page_site = (
get_element_options(None, "site", "site", "site")
or page_url.netloc
)
page_description = get_element_options(
None, "description", "description", "description"
)
page_image = get_element_options(None, "image", "image", "image")
page_image_alt = get_element_options(
None, "image:alt", "image:alt", "image:alt"
)
page_video = get_element_options(None, "video", "video", "video")
page_audio = get_element_options(None, "audio", "audio", "audio")
preview_size = (
get_element_options(None, None, None, "card")
or "summary_large_image"
)
attachment_base = BeautifulSoup(str(element), "html.parser")
attachments[original_link_name] = attachment_base
attachment = next(attachment_base.children)
attachment.clear()
attachment.attrs["class"] = "embed-url-link"
render_element = attachment
if page_image:
image_template = f'<span><img src="{page_image}" alt="{page_image_alt or page_name}" title="{page_name}" width="420" height="240" /></span>'
render_element.append(
BeautifulSoup(image_template, "html.parser")
)
if page_video:
video_template = f'<video controls><source src="{page_video}">Your browser does not support the video tag.</video>'
render_element.append(
BeautifulSoup(video_template, "html.parser")
)
if page_audio:
audio_template = f'<audio controls><source src="{page_audio}">Your browser does not support the audio tag.</audio>'
render_element.append(
BeautifulSoup(audio_template, "html.parser")
)
description_element_base = BeautifulSoup(
"<span class='description'></span>", "html.parser"
)
description_element = next(description_element_base.children)
description_element.append(
BeautifulSoup(
f'<p class="page-site">{page_site}</p>',
"html.parser",
)
)
description_element.append(
BeautifulSoup(f'<strong class="page-name">{page_name}</strong>', "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-description'>{page_description or "No description available."}</p>", "html.parser")
)
description_element.append(
BeautifulSoup(f"<p class='page-original-link'>{original_link_name}</p>", "html.parser")
)
render_element.append(description_element_base)
for attachment in attachments.values():
soup.append(attachment)
return str(soup)
class EmojiExtension(Extension):
tags = {"emoji"}
@@ -276,6 +425,9 @@ class LinkifyExtension(Extension):
result = embed_youtube(result)
result = enrich_image_rendering(result)
result = embed_url(result)
return result
+1 -1
View File
@@ -1 +1 @@
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
<div style="max-width:100%;" data-uid="{{uid}}" data-color="{{color}}" data-channel_uid="{{channel_uid}}" data-user_nick="{{user_nick}}" data-created_at="{{created_at}}" data-user_uid="{{user_uid}}" class="message"><a class="avatar" style="background-color: {{color}}; color: black;" href="/user/{{user_uid}}.html"><img class="avatar-img" width="40px" height="40px" src="/avatar/{{user_uid}}.svg" /></a><div class="message-content"><div class="author" style="color: {{color}};">{{user_nick}}</div><div class="text">{% autoescape false %}{% emoji %}{% linkify %}{% markdown %}{% autoescape false %}{{ message }}{%raw %} {% endraw%}{%endautoescape%}{% endmarkdown %}{% endlinkify %}{% endemoji %}{% endautoescape %}</div><div class="time no-select" data-created_at="{{created_at}}"></div></div></div>
+32 -31
View File
@@ -13,7 +13,7 @@
{% endfor %}
<div class="message-list-bottom"></div>
</message-list>
<chat-input live-type="false" channel="{{ channel.uid.value }}"></chat-input>
<chat-input live-type="true" channel="{{ channel.uid.value }}"></chat-input>
</section>
{% include "dialog_help.html" %}
{% include "dialog_online.html" %}
@@ -157,6 +157,10 @@ app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message);
setTimeout(() => window.location.reload(), 4000);
});
app.ws.addEventListener("stars_render", (data)=>{
app.starField.renderWord(data.message, { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
})
app.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000);
@@ -167,7 +171,9 @@ app.ws.addEventListener("starfield.render_word", (data) => {
// --- Channel message event ---
app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data);
@@ -179,13 +185,22 @@ app.addEventListener("channel-message", (data) => {
if (isMentionToMe(data.message)) {
app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) {
app.playSound("message");
if(data.is_final){
app.playSound("message");
}
}
}
const lastMessage = messagesContainer.querySelector(".message:last-child");
const lastElement = messagesContainer.querySelector(".message-list-bottom");
const doScrollDown = !lastMessage || isElementVisible(lastMessage);
const doScrollDown = messagesContainer.isScrolledToBottom();
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
if (oldMessage) {
oldMessage.remove();
}
const message = document.createElement("div");
message.innerHTML = data.html;
message.style.display = display;
messagesContainer.insertBefore(message.firstChild, lastElement);
@@ -211,6 +226,18 @@ document.addEventListener('keydown', function(event) {
loadExtra();
}
}
if(event.key == 'i' && !chatInputField.isActive()) {
event.preventDefault();
chatInputField.focus();
}
if(event.key == 'r' && !chatInputField.isActive()) {
event.preventDefault();
const replyLinks = document.querySelectorAll('.time a')
if(replyLinks.length){
const replyLink = replyLinks[replyLinks.length - 1]
replyLink.click()
}
}
if (event.shiftKey && event.key === 'G') {
if (!chatInputField.isActive()) {
event.preventDefault();
@@ -221,24 +248,7 @@ document.addEventListener('keydown', function(event) {
});
// --- Image click-to-zoom (delegated) ---
messagesContainer.addEventListener('click', (e) => {
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar')) return;
const img = e.target;
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
const fullImg = document.createElement('img');
const urlObj = new URL(img.src); urlObj.search = '';
fullImg.src = urlObj.toString();
fullImg.alt = img.alt;
fullImg.style.maxWidth = '90%';
fullImg.style.maxHeight = '90%';
overlay.appendChild(fullImg);
document.body.appendChild(overlay);
overlay.addEventListener('click', () => document.body.removeChild(overlay));
});
// --- Layout update ---
let lastMessage;
function updateLayout(doScrollDown) {
updateTimes();
let previousUser = null, previousDate = null;
@@ -262,20 +272,11 @@ function updateLayout(doScrollDown) {
}
}
});
lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) messagesContainer.scrollToBottom?.();
}
// --- Utility: check if element is visible ---
function isElementVisible(element) {
const rect = element.getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
// --- Utility: check if scrolled past half ---
function isScrolledPastHalf() {
+67
View File
@@ -0,0 +1,67 @@
# Written by retoor@molodetz.nl
# This code defines a WebView class that inherits from BaseView and includes a method for rendering a web template, requiring login access for its usage.
# The code imports the BaseView class from the `snek.system.view` module.
# MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import uuid
from aiohttp import web
from multiavatar import multiavatar
from snek.system.view import BaseView
from snek.view.avatar_animal import generate_avatar_with_options
import functools
class AvatarView(BaseView):
login_required = False
def __init__(self, *args,**kwargs):
super().__init__(*args,**kwargs)
self.avatars = {}
async def get(self):
uid = self.request.match_info.get("uid")
while True:
try:
return web.Response(text=self._get(uid), content_type="image/svg+xml")
except Exception as e:
pass
def _get(self, uid):
if uid in self.avatars:
return self.avatars[uid]
avatar = generate_avatar_with_options(self.request.query)
self.avatars[uid] = avatar
return avatar
async def get2(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
avatar = multiavatar.multiavatar(uid, True, None)
response = web.Response(text=avatar, content_type="image/svg+xml")
response.headers["Cache-Control"] = f"public, max-age={1337*42}"
return response
+40 -9
View File
@@ -193,6 +193,21 @@ class RPCView(BaseView):
)
return channels
async def finalize_message(self, message_uid):
self._require_login()
message = await self.services.channel_message.get(message_uid)
if not message:
return False
if message["user_uid"] != self.user_uid:
raise Exception("Not allowed")
if not message['is_final']:
await self.services.chat.finalize(message['uid'])
return True
async def update_message_text(self,message_uid, text):
self._require_login()
message = await self.services.channel_message.get(message_uid)
@@ -200,6 +215,7 @@ class RPCView(BaseView):
raise Exception("Not allowed")
if message.get_seconds_since_last_update() > 3:
await self.finalize_message(message["uid"])
return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
message['message'] = text
@@ -221,9 +237,9 @@ class RPCView(BaseView):
return {"success": True}
async def send_message(self, channel_uid, message):
async def send_message(self, channel_uid, message,is_final=True):
self._require_login()
message = await self.services.chat.send(self.user_uid, channel_uid, message)
message = await self.services.chat.send(self.user_uid, channel_uid, message,is_final)
return message["uid"]
@@ -314,19 +330,28 @@ class RPCView(BaseView):
self._require_login()
results = [
record.record async for record in self.services.channel.get_online_users(channel_uid)
record async for record in self.services.channel.get_recent_users(channel_uid)
]
for result in results:
del result['email']
del result['password']
del result['deleted_at']
del result['updated_at']
results = sorted(results, key=lambda x: x["nick"])
return results
async def echo(self, obj):
await self.ws.send_json(obj)
return "noresponse"
async def get_recent_users(self, channel_uid):
self._require_login()
return [
{
"uid": record["uid"],
"username": record["username"],
"nick": record["nick"],
"last_ping": record["last_ping"],
}
async for record in self.services.channel.get_recent_users(channel_uid)
]
async def get_users(self, channel_uid):
self._require_login()
@@ -355,7 +380,13 @@ class RPCView(BaseView):
return {"pong": args}
async def stars_render(self, channel_uid, message):
for user in await self.get_online_users(channel_uid):
try:
await self.services.socket.send_to_user(user['uid'], dict(event="stars_render", data={"channel_uid": channel_uid, "message":message}))
except Exception as ex:
print(ex)
async def get(self):
scheduled = []
@@ -382,7 +413,7 @@ class RPCView(BaseView):
"message": "Finishing deployment"}
}
)
await schedule(self.request.session.get("uid"),10,{"event": "deployed", "data": {
await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
"uptime": self.request.app.uptime}
}
)
+10
View File
@@ -55,6 +55,16 @@ class WebView(BaseView):
user_uid=self.session.get("uid"), channel_uid=channel["uid"]
)
if not channel_member:
if not channel["is_private"]:
channel_member = await self.app.services.channel_member.create(
channel_uid=channel["uid"],
user_uid=self.session.get("uid"),
is_moderator=False,
is_read_only=False,
is_muted=False,
is_banned=False,
)
return web.HTTPNotFound()
channel_member["new_count"] = 0