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
retoor 8d2e0381a7 Merge pull request 'Enhance mobile responsiveness by updating viewport settings and restructuring layout' (#47) from BordedDev/snek:bugfix/page-resize-on-mobile into main
Reviewed-on: #47
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:27:33 +02:00
retoor f67d7b35f1 Merge pull request 'Potential missing letter fix' (#46) from BordedDev/snek:bugfix/potential-lag-fix into main
Reviewed-on: #46
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-27 00:25:56 +02:00
BordedDev 9ec62f7471 Enhance mobile responsiveness by updating viewport settings and restructuring layout 2025-05-27 00:23:41 +02:00
retoor 6bbbc41360 Update. 2025-05-25 20:50:32 +02:00
retoor d3844ac7a7 Resoted avatars. 2025-05-25 19:01:28 +02:00
retoor 9378e95a5b Resoted avatars. 2025-05-25 18:55:39 +02:00
retoor 5e4c4ce228 Resoted avatars. 2025-05-25 18:51:55 +02:00
retoor ffc373db62 Channel support. 2025-05-25 12:30:43 +02:00
retoor 60266bf0dc Channel support. 2025-05-25 12:30:26 +02:00
BordedDev 8393a80022 Potential missing letter fix 2025-05-25 02:46:49 +02:00
retoor 234edf4756 Merge pull request 'Fixed double messaging when live typing' (#45) from BordedDev/snek:bugfix/live-typing into main
Reviewed-on: #45
2025-05-25 02:29:04 +02:00
BordedDev 5fd401bfb6 Fixed double messaging when live typing 2025-05-25 02:23:52 +02:00
retoor 5663a5f376 Update. 2025-05-25 01:28:42 +02:00
retoor 81327a9e20 Update. 2025-05-25 01:25:48 +02:00
retoor 2a5b9ad276 Merge pull request 'Fix CSS selector for the last message display' (#44) from BordedDev/snek:bugfix/fix-last-message-time-reply into main
Reviewed-on: #44
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 18:46:43 +02:00
BordedDev 662e71c621 Fix CSS selector for the last message display 2025-05-24 18:41:12 +02:00
retoor 636adfd997 Update performance. 2025-05-24 15:00:26 +02:00
retoor b94f7a9532 Update. 2025-05-24 14:45:53 +02:00
retoor f954a34384 Update performance. 2025-05-24 14:22:12 +02:00
retoor 6a74263606 Update performance. 2025-05-24 14:18:38 +02:00
retoor 0bf714061c Update. 2025-05-24 12:58:49 +02:00
retoor e4e2e919c2 Update. 2025-05-24 12:58:49 +02:00
retoor 7fe4289f42 Merge pull request 'Made datetime/reply visible when date range is long, also fixes opacity to 1 for the last message's time display' (#39) from BordedDev/snek:bugfix/opacity-for-last-message into main
Reviewed-on: #39
Reviewed-by: retoor <retoor@noreply@molodetz.nl>
2025-05-24 01:48:07 +02:00
BordedDev 3ce866b7da Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 18:14:32 +02:00
BordedDev 2c506db4e4 Merge branch 'main' into bugfix/opacity-for-last-message 2025-05-23 15:34:30 +02:00
BordedDev 6b083f8b1b Added feature to show time and reply when messages are from a long time before the next message 2025-05-22 00:19:39 +02:00
BordedDev 89afbba165 Set opacity to 1 for the last message's time display 2025-05-22 00:00:51 +02:00
25 changed files with 978 additions and 2218 deletions
-1489
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
+6 -1
View File
@@ -56,6 +56,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView
from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication
from snek.sgit import GitApplication
@@ -221,6 +222,7 @@ class Application(BaseApplication):
self.router.add_get("/http-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView)
self.router.add_get("/c/{channel:.*}", ChannelView)
self.router.add_view("/channel/{channel_uid}/attachment.bin",ChannelAttachmentView)
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
self.router.add_view("/channel/{channel}.html", WebView)
@@ -277,11 +279,14 @@ class Application(BaseApplication):
async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
):
parent_object = await subscribed_channel.get_channel()
item = {}
other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid")
)
parent_object = await subscribed_channel.get_channel()
last_message = await parent_object.get_last_message()
color = None
if last_message:
+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}")
+9 -3
View File
@@ -160,10 +160,14 @@ export class App extends EventHandler {
typeLock = null;
typeListener = null;
typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid;
}
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) {
if (this.is_pinging) return false;
this.is_pinging = true;
@@ -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;
+84 -20
View File
@@ -5,6 +5,10 @@
box-sizing: border-box;
}
html {
height: 100%;
}
.gallery {
padding: 50px;
height: auto;
@@ -25,24 +29,26 @@ body {
background-color: #000000;
color: #e6e6e6;
line-height: 1.5;
display: flex;
flex-direction: column;
height: 100vh;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%;
height: 100%;
}
main {
display: flex;
flex: 1;
overflow: hidden;
grid-area: chat-area;
}
header {
background-color: #000000;
padding-top: 10px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 10px;
grid-area: header;
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -95,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;
}
@@ -132,7 +136,11 @@ footer {
-ms-overflow-style: none;
padding: 10px;
height: 10px;
background: #000000;
}
.chat-messages {
display: flex;
flex-direction: column;
}
.container {
@@ -151,9 +159,11 @@ footer {
}
}
.chat-messages picture img {
cursor: pointer;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
@@ -230,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;
@@ -306,9 +315,10 @@ input[type="text"], .chat-input textarea {
}
}
.message:has(+ .message.switch-user), .message:last-child {
.message:has(+ .message.switch-user), .message:has(+ .message.long-time), .message:not(:has(+ .message)) {
.time {
display: block;
opacity: 1;
}
}
@@ -342,11 +352,11 @@ a {
.sidebar {
width: 250px;
background-color: #000000;
padding-left: 20px;
padding-right: 20px;
padding-top: 10px;
overflow-y: auto;
grid-area: sidebar;
}
.sidebar h2 {
@@ -391,7 +401,6 @@ a {
}
@media only screen and (max-width: 768px) {
header {
@@ -401,17 +410,21 @@ a {
width: 100%;
display: flex;
flex-direction: column;
.logo {
display: block;
flex: 1;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
h2 {
font-size: 14px;
}
text-align: center;
}
nav {
text-align: right;
flex: 1;
@@ -493,13 +506,23 @@ dialog .dialog-button {
@keyframes dialogFadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialogScaleIn {
from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; }
to { transform: scale(1) translate(-50%, -50%); opacity: 1; }
from {
transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
}
dialog .dialog-button.primary {
@@ -521,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;
}
+148 -25
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;
}
@@ -46,6 +59,97 @@ class ChatInputComponent extends HTMLElement {
focus() {
this.textarea.focus();
}
getAuthors(){
let authors = []
for (let i = 0; i < this.users.length; i++) {
authors.push(this.users[i].username)
authors.push(this.users[i].nick)
}
return authors
}
extractMentions(text) {
const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = [];
let match;
while ((match = regex.exec(text)) !== null) {
mentions.push(match[1]);
}
return mentions;
}
matchMentionsToAuthors(mentions, authors) {
return mentions.map(mention => {
let closestAuthor = null;
let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => {
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 };
});
}
levenshteinDistance(a, b) {
const matrix = [];
// Initialize the first row and column
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i];
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j;
}
// Fill in the matrix
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b.charAt(i - 1) === a.charAt(j - 1)) {
matrix[i][j] = matrix[i - 1][j - 1];
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // Deletion
matrix[i][j - 1] + 1, // Insertion
matrix[i - 1][j - 1] + 1 // Substitution
);
}
}
}
return matrix[b.length][a.length];
}
replaceMentionsWithAuthors(text) {
const authors = this.getAuthors();
const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors);
let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g');
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
});
return updatedText;
}
async connectedCallback() {
@@ -53,10 +157,17 @@ class ChatInputComponent extends HTMLElement {
app.rpc.getUser(null).then((user) => {
this.user=user
})
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");
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
})
this.messageUid = null;
this.classList.add("chat-input");
@@ -90,9 +201,10 @@ class ChatInputComponent extends HTMLElement {
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;
@@ -102,30 +214,27 @@ class ChatInputComponent extends HTMLElement {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const message = e.target.value;
this.messageUid = null;
this.value = "";
this.previousValue = "";
const message = me.replaceMentionsWithAuthors(this.value);
e.target.value = "";
if (!message) {
return;
}
let autoCompletion = this.autoCompletions[message];
if (autoCompletion) {
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
if (autoCompletionHandler) {
autoCompletionHandler();
this.value = "";
this.previousValue = "";
e.target.value = "";
autoCompletion();
return;
}
e.target.value = "";
this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = "";
this.previousValue = "";
this.messageUid = null;
this.sendMessage(this.channelUid, message).then((uid) => {
this.messageUid = uid;
});
}
});
@@ -154,7 +263,7 @@ class ChatInputComponent extends HTMLElement {
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();
@@ -165,14 +274,28 @@ class ChatInputComponent extends HTMLElement {
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;
}
});
}
@@ -192,7 +315,7 @@ class ChatInputComponent extends HTMLElement {
app.rpc &&
typeof app.rpc.updateMessageText === "function"
) {
app.rpc.updateMessageText(this.messageUid, this.value);
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
}
}
@@ -239,11 +362,11 @@ class ChatInputComponent extends HTMLElement {
}
}
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)
+1
View File
@@ -25,6 +25,7 @@
border-radius: 50%;
background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out;
z-index: -1;
}
@keyframes twinkle {
+7
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,6 +114,11 @@ export class Socket extends EventHandler {
get(_, prop) {
return (...args) => {
const functionName = me._camelToSnake(prop);
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
+3 -2
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content">
<link rel="manifest" href="/manifest.json" />
<title>Snek</title>
<style>{{highlight_styles}}</style>
@@ -50,10 +50,11 @@
</nav>
</header>
<main>
{% block sidebar %}
{% include "sidebar_channels.html" %}
{% endblock %}
<main>
{% block main %}
<chat-window class="chat-area"></chat-window>
+1 -1
View File
@@ -21,7 +21,7 @@
<script src="/html-frame.js" type="module"></script>
<script src="/generic-form.js?rid={{ rid }}" type="module"></script>
<link rel="stylesheet" href="/html-frame.css">
<script defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
<script async defer src="https://umami.molodetz.nl/script.js" data-website-id="d127c3e4-dc70-4041-a1c8-bcc32c2492ea"></script>
{% block head %}
{% endblock %}
</head>
+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>
+192 -240
View File
@@ -13,330 +13,282 @@
{% 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" %}
<script type="module">
import { app } from "/app.js";
import { Schedule } from "/schedule.js";
const channelUid = "{{ channel.uid.value }}";
const chatInputField = document.querySelector("chat-input");
chatInputField.autoCompletions = {
"/online": () =>{
showOnline();
},
"/clear": () => {
document.querySelector(".chat-messages").innerHTML = '';
},
"/live": () =>{
chatInputField.liveType = !chatInputField.liveType
},
"/help": () => {
showHelp();
// --- Cache selectors ---
const chatInputField = document.querySelector("chat-input");
const messagesContainer = document.querySelector(".chat-messages");
const chatArea = document.querySelector(".chat-area");
const channelUid = "{{ channel.uid.value }}";
const username = "{{ user.username.value }}";
// --- Command completions ---
chatInputField.autoCompletions = {
"/online": showOnline,
"/clear": () => { messagesContainer.innerHTML = ''; },
"/live": () => { chatInputField.liveType = !chatInputField.liveType; },
"/help": showHelp
};
// --- Throttle utility ---
function throttle(fn, wait) {
let last = 0;
return function(...args) {
const now = Date.now();
if (now - last >= wait) {
last = now;
fn.apply(this, args);
}
};
}
app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message);
setTimeout(() => {
window.location.reload();
},4000)
})
app.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed",{"rainbow":true,"resolution":8});
setTimeout(() => {
app.starField.shuffleAll(5000);
},10000)
})
app.ws.addEventListener("starfield.render_word", (data) => {
app.starField.renderWord(data.word,data);
})
const textBox = document.querySelector("chat-input").textarea
// --- Scroll: load extra messages, throttled ---
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
if (messages.length) {
const frag = document.createDocumentFragment();
messages.forEach(msg => {
const temp = document.createElement("div");
temp.innerHTML = msg.html;
frag.appendChild(temp.firstChild);
});
firstMessage.parentNode.insertBefore(frag, firstMessage);
updateLayout(false);
}
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
// --- Only update visible times ---
function updateTimes() {
const containers = messagesContainer.querySelectorAll(".time");
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
containers.forEach(container => {
const rect = container.getBoundingClientRect();
if (rect.top >= 0 && rect.bottom <= viewportHeight) {
const messageDiv = container.closest('.message');
const text = messageDiv.querySelector(".text").innerText;
const time = document.createElement("span");
time.innerText = app.timeDescription(container.dataset.created_at);
container.replaceChildren(time);
const reply = document.createElement("a");
reply.innerText = " reply";
reply.href = "#reply";
container.appendChild(reply);
reply.addEventListener('click', e => {
e.preventDefault();
replyMessage(text);
});
}
});
}
setInterval(() => requestIdleCallback(updateTimes), 30000);
// --- Paste & drag/drop uploads ---
const textBox = chatInputField.textarea;
textBox.addEventListener("paste", async (e) => {
try {
const clipboardItems = await navigator.clipboard.read();
// DataTransfer needs to be used to modify the files list of the input
const dt = new DataTransfer();
for (const clipboardItem of clipboardItems) {
const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))
for (const fileType of fileTypes) {
const blob = await clipboardItem.getType(fileType);
// Do something with the image blob.
dt.items.add(new File([blob], "image.png", { type: fileType }));
for (const item of clipboardItems) {
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
const blob = await item.getType(type);
dt.items.add(new File([blob], "image.png", { type }));
}
}
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton
const input = uploadButton.shadowRoot.querySelector('.file-input')
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
} catch (error) {
console.error("Failed to read clipboard contents: ", error);
}
});
const chatInput = document.querySelector(".chat-area")
chatInput.addEventListener("drop", async (e) => {
chatArea.addEventListener("drop", async (e) => {
e.preventDefault();
const dt = e.dataTransfer;
if (dt.items.length > 0) {
const uploadButton = chatInputField.uploadButton
const input = uploadButton.shadowRoot.querySelector('.file-input')
const uploadButton = chatInputField.uploadButton;
const input = uploadButton.shadowRoot.querySelector('.file-input');
input.files = dt.files;
await uploadButton.uploadFiles();
}
})
chatInput.addEventListener("dragover", async (e) => {
});
chatArea.addEventListener("dragover", e => {
e.preventDefault();
e.dataTransfer.dropEffect = "link";
});
})
// --- Focus input on load ---
chatInputField.textarea.focus();
// --- Reply helper ---
function replyMessage(message) {
const field = chatInputField
field.value = "```markdown\n> " + (message || '') + "\n```\n";
field.focus();
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
chatInputField.focus();
}
function updateTimes() {
document.querySelectorAll(".time").forEach((container) => {
const messageDiv = container.closest('.message');
const userNick = messageDiv.dataset.user_nick;
const text = messageDiv.querySelector(".text").innerText;
const time = document.createElement("span");
time.innerText = app.timeDescription(container.dataset.created_at);
container.replaceChildren(time);
const reply = document.createElement("a");
reply.innerText = " reply";
reply.href = "#reply";
container.appendChild(reply);
reply.addEventListener('click', (e) => {
e.preventDefault();
replyMessage(text);
})
});
}
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)
);
}
const messagesContainer = document.querySelector(".chat-messages");
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
if (scrollTop < scrollableHeight / 2) {
return true;
}
return false;
}
let isLoadingExtra = false;
async function loadExtra() {
const firstMessage = messagesContainer.querySelector(".message:first-child");
if (isLoadingExtra) {
return;
}
if (!isScrolledPastHalf()) {
return;
}
isLoadingExtra = true;
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
messages.forEach((message) => {
firstMessage.insertAdjacentHTML("beforebegin", message.html);
})
updateLayout(false);
isLoadingExtra = false;
}
messagesContainer.addEventListener("scroll", () => {
loadExtra();
});
let lastMessage
function updateLayout(doScrollDown) {
const messagesContainer = document.querySelector(".chat-messages");
updateTimes();
let previousUser = null;
document.querySelectorAll(".message").forEach((message) => {
if (previousUser !== message.dataset.user_uid) {
message.classList.add("switch-user");
previousUser = message.dataset.user_uid;
} else {
message.classList.remove("switch-user");
}
});
lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) {
messagesContainer.scrollToBottom()
}
}
setInterval(updateTimes, 30000);
function isMentionToMe(message){
const mentionText = '@{{ user.username.value }}';
return message.toLowerCase().includes(mentionText);
}
// --- Mention helpers ---
function extractMentions(message) {
return [...new Set(message.match(/@\w+/g) || [])];
}
function isMentionToMe(message) {
return message.toLowerCase().includes('@' + username.toLowerCase());
}
function isMentionForSomeoneElse(message) {
const mentions = extractMentions(message);
const mentionText = '@{{ user.username.value }}';
return mentions.length > 0 && mentions.indexOf(mentionText) == -1;
return mentions.length > 0 && !mentions.includes('@' + username);
}
// --- WebSocket events ---
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);
});
app.ws.addEventListener("starfield.render_word", (data) => {
app.starField.renderWord(data.word, data);
});
// --- Channel message event ---
app.addEventListener("channel-message", (data) => {
let display = 'block';
if(!data.text || !data.text.trim()){
display = "none";
}
let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data);
app.playSound("messageOtherChannel");
}
return;
}
if (data.username !== "{{ user.username.value }}") {
if (data.username !== username) {
if (isMentionToMe(data.message)) {
app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) {
if(data.is_final){
app.playSound("message");
}
}
}
const lastElement = messagesContainer.querySelector(".message-list-bottom");
const doScrollDown = messagesContainer.isScrolledToBottom();
const messagesContainer = document.querySelector(".chat-messages");
lastMessage = messagesContainer.querySelector(".message:last-child");
const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);
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
document.querySelector(".chat-messages").appendChild(message.firstChild);
updateLayout(doScrollDownBecauseLastMessageIsVisible);
setTimeout(() => {
updateLayout(doScrollDownBecauseLastMessageIsVisible)
}, 1000);
message.style.display = display;
messagesContainer.insertBefore(message.firstChild, lastElement);
updateLayout(doScrollDown);
setTimeout(() => updateLayout(doScrollDown), 1000);
app.rpc.markAsRead(channelUid);
});
let escPressed = false;
let gPressCount = 0;
let keyTimeout;
// --- Keyboard shortcuts ---
let escPressed = false, gPressCount = 0, keyTimeout;
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
escPressed = true;
gPressCount = 0;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
escPressed = false;
}, 300);
document.activeElement.blur();
}
if (event.key === 'G' && escPressed) {
if (event.key === 'g' && !chatInputField.isActive()) {
gPressCount++;
clearTimeout(keyTimeout);
keyTimeout = setTimeout(() => {
gPressCount = 0;
}, 300);
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
if (gPressCount === 2) {
gPressCount = 0;
escPressed = false;
messagesContainer.querySelector(".message:last-child").scrollIntoView({ block: "end", inline: "nearest" });
setTimeout(() => {
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
loadExtra();
}
}
if(event.key == 'i' && !chatInputField.isActive()) {
event.preventDefault();
chatInputField.focus();
},500)
}
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()){
if (!chatInputField.isActive()) {
event.preventDefault();
updateLayout(true);
setTimeout(() => {
chatInputField.focus();
},500)
setTimeout(() => chatInputField.focus(), 500);
}
}
});
messagesContainer.addEventListener('click', (e) => {
if(e.target.tagName != 'IMG')
return
const img = e.target
if(e.target.classList.contains('avatar')){
return
// --- Image click-to-zoom (delegated) ---
// --- Layout update ---
function updateLayout(doScrollDown) {
updateTimes();
let previousUser = null, previousDate = null;
messagesContainer.querySelectorAll(".message").forEach((message) => {
if (previousUser !== message.dataset.user_uid) {
message.classList.add("switch-user");
previousUser = message.dataset.user_uid;
previousDate = new Date(message.dataset.created_at);
} else {
message.classList.remove("switch-user");
if (!previousDate) {
previousDate = new Date(message.dataset.created_at);
} else {
const currentDate = new Date(message.dataset.created_at);
if (currentDate.getTime() - previousDate.getTime() > 1000 * 60 * 20) {
message.classList.add("long-time");
} else {
message.classList.remove("long-time");
}
previousDate = currentDate;
}
}
const overlay = document.createElement('div');
overlay.style.position = 'fixed';
overlay.style.top = 0;
overlay.style.left = 0;
overlay.style.width = '100%';
overlay.style.height = '100%';
overlay.style.backgroundColor = 'rgba(0,0,0,0.9)';
overlay.style.display = 'flex';
overlay.style.justifyContent = 'center';
overlay.style.alignItems = 'center';
overlay.style.zIndex = 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);
});
});
if (doScrollDown) messagesContainer.scrollToBottom?.();
}
// --- Utility: check if element is visible ---
// --- Utility: check if scrolled past half ---
function isScrolledPastHalf() {
let scrollTop = messagesContainer.scrollTop;
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
return scrollTop < scrollableHeight / 2;
}
// --- Initial layout update ---
updateLayout(true);
</script>
{% endblock %}
+21 -6
View File
@@ -41,23 +41,38 @@ class AvatarView(BaseView):
self.avatars = {}
async def get(self):
type_ = self.request.query.get("type")
type_match = {
"animal": self.get_animal,
"default": self.get_default
}
handler = type_match.get(type_, self.get_default)
return await handler()
async def get_animal(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
key = "avatar_animal_" + uid
avatar = await self.app.get(key)
if avatar:
return web.Response(text=avatar, content_type="image/svg+xml")
while True:
try:
return web.Response(text=self._get(uid), content_type="image/svg+xml")
avatar = generate_avatar_with_options(self.request.query)
await self.app.set(key, avatar)
return web.Response(text=avatar, 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):
async def get_default(self):
uid = self.request.match_info.get("uid")
if uid == "unique":
uid = str(uuid.uuid4())
+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
+21
View File
@@ -137,3 +137,24 @@ class ChannelAttachmentView(BaseView):
"channel_uid": channel_uid,
}
)
class ChannelView(BaseView):
async def get(self):
channel_name = self.request.match_info.get("channel")
if(channel_name is None):
return web.HTTPNotFound()
channel = await self.services.channel.get(label="#" + channel_name)
if(channel is None):
channel = await self.services.channel.get(label=channel_name)
channel = await self.services.channel.get(channel_name)
if(channel is None):
channel = await self.services.channel.get(label=channel_name)
if(channel is None):
user = await self.services.user.get(uid=self.session.get("uid"))
is_listed = self.request.query.get("listed", False) == "true"
is_private = self.request.query.get("private", False) == "true"
channel = await self.services.channel.create(label=channel_name,created_by_uid=user['uid'],description="No description provided.",tag="user",is_private=is_private,is_listed=is_listed)
channel_member = await self.services.channel_member.create(channel_uid=channel['uid'],user_uid=user['uid'],is_moderator=True,is_read_only=False,is_muted=False,is_banned=False)
return web.HTTPFound("/channel/{}.html".format(channel["uid"]))
+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