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
25 changed files with 705 additions and 3053 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
+6 -1
View File
@@ -56,6 +56,7 @@ from snek.view.upload import UploadView
from snek.view.user import UserView from snek.view.user import UserView
from snek.view.web import WebView from snek.view.web import WebView
from snek.view.channel import ChannelAttachmentView from snek.view.channel import ChannelAttachmentView
from snek.view.channel import ChannelView
from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView from snek.view.settings.containers import ContainersIndexView, ContainersCreateView, ContainersUpdateView, ContainersDeleteView
from snek.webdav import WebdavApplication from snek.webdav import WebdavApplication
from snek.sgit import GitApplication 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-get", self.handle_http_get)
self.router.add_get("/http-photo", self.handle_http_photo) self.router.add_get("/http-photo", self.handle_http_photo)
self.router.add_get("/rpc.ws", RPCView) 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/{channel_uid}/attachment.bin",ChannelAttachmentView)
self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView) self.router.add_view("/channel/attachment/{relative_url:.*}",ChannelAttachmentView)
self.router.add_view("/channel/{channel}.html", WebView) 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( async for subscribed_channel in self.services.channel_member.find(
user_uid=request.session.get("uid"), deleted_at=None, is_banned=False user_uid=request.session.get("uid"), deleted_at=None, is_banned=False
): ):
parent_object = await subscribed_channel.get_channel()
item = {} item = {}
other_user = await self.services.channel_member.get_other_dm_user( other_user = await self.services.channel_member.get_other_dm_user(
subscribed_channel["channel_uid"], request.session.get("uid") subscribed_channel["channel_uid"], request.session.get("uid")
) )
parent_object = await subscribed_channel.get_channel()
last_message = await parent_object.get_last_message() last_message = await parent_object.get_last_message()
color = None color = None
if last_message: if last_message:
+1
View File
@@ -7,6 +7,7 @@ class ChannelMessageModel(BaseModel):
user_uid = ModelField(name="user_uid", required=True, kind=str) user_uid = ModelField(name="user_uid", required=True, kind=str)
message = ModelField(name="message", required=True, kind=str) message = ModelField(name="message", required=True, kind=str)
html = ModelField(name="html", required=False, 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): def get_seconds_since_last_update(self):
return int((datetime.now(timezone.utc) - datetime.fromisoformat(self["updated_at"])).total_seconds()) 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) await self.services.channel_member.create_dm(channel["uid"], user1, user2)
return channel 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 def get_users(self, channel_uid):
async for channel_member in self.services.channel_member.find( async for channel_member in self.services.channel_member.find(
channel_uid=channel_uid, channel_uid=channel_uid,
+13 -2
View File
@@ -4,12 +4,13 @@ from snek.system.service import BaseService
class ChannelMessageService(BaseService): class ChannelMessageService(BaseService):
mapper_name = "channel_message" 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 = await self.new()
model["channel_uid"] = channel_uid model["channel_uid"] = channel_uid
model["user_uid"] = user_uid model["user_uid"] = user_uid
model["message"] = message model["message"] = message
model['is_final'] = is_final
context = {} context = {}
@@ -51,7 +52,17 @@ class ChannelMessageService(BaseService):
} }
async def save(self, model): 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") template = self.app.jinja2_env.get_template("message.html")
model["html"] = template.render(**context) model["html"] = template.render(**context)
return await super().save(model) return await super().save(model)
+29 -2
View File
@@ -4,12 +4,38 @@ from snek.system.service import BaseService
class ChatService(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) channel = await self.services.channel.get(uid=channel_uid)
if not channel: if not channel:
raise Exception("Channel not found.") raise Exception("Channel not found.")
channel_message = await self.services.channel_message.create( 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"] channel_message_uid = channel_message["uid"]
@@ -30,6 +56,7 @@ class ChatService(BaseService):
"username": user["username"], "username": user["username"],
"uid": channel_message["uid"], "uid": channel_message["uid"],
"user_nick": user["nick"], "user_nick": user["nick"],
"is_final": is_final,
}, },
) )
await self.app.create_task( 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") logger.info("Starting SFTP server setup")
host_key_path = Path("drive") / ".ssh" / "sftp_server_key" 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: try:
if not host_key_path.exists(): if not host_key_path.exists():
logger.info(f"Generating new host key at {host_key_path}") 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; typeLock = null;
typeListener = null; typeListener = null;
typeEventChannelUid = null; typeEventChannelUid = null;
_debug = false
async set_typing(channel_uid) { async set_typing(channel_uid) {
this.typeEventChannel_uid = channel_uid; this.typeEventChannel_uid = channel_uid;
} }
debug() {
this._debug = !this._debug;
this.ws._debug = this._debug;
}
async ping(...args) { async ping(...args) {
if (this.is_pinging) return false; if (this.is_pinging) return false;
this.is_pinging = true; this.is_pinging = true;
@@ -202,8 +206,10 @@ export class App extends EventHandler {
this.ws.addEventListener("channel-message", (data) => { this.ws.addEventListener("channel-message", (data) => {
me.emit("channel-message", data); me.emit("channel-message", data);
}); });
this.ws.addEventListener("event", (data) => { this.ws.addEventListener("data", (data) => {
console.info("aaaa"); if(this._debug){
console.debug(data)
}
}); });
this.rpc.getUser(null).then((user) => { this.rpc.getUser(null).then((user) => {
me.user = user; me.user = user;
+82 -19
View File
@@ -5,6 +5,10 @@
box-sizing: border-box; box-sizing: border-box;
} }
html {
height: 100%;
}
.gallery { .gallery {
padding: 50px; padding: 50px;
height: auto; height: auto;
@@ -25,24 +29,26 @@ body {
background-color: #000000; background-color: #000000;
color: #e6e6e6; color: #e6e6e6;
line-height: 1.5; line-height: 1.5;
display: flex; display: grid;
flex-direction: column; grid-template-columns: auto 1fr;
height: 100vh; grid-template-rows: auto 1fr;
grid-template-areas:
"header header"
"sidebar chat-area";
min-width: 100%; min-width: 100%;
height: 100%;
} }
main { main {
display: flex; display: flex;
flex: 1; flex: 1;
overflow: hidden; overflow: hidden;
grid-area: chat-area;
} }
header { header {
background-color: #000000; grid-area: header;
padding-top: 10px; padding: 10px 20px;
padding-left: 20px;
padding-right: 20px;
padding-bottom: 10px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -95,13 +101,11 @@ h2 {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: #000000;
overflow: hidden; overflow: hidden;
} }
.chat-header { .chat-header {
padding: 10px 20px; padding: 10px 20px;
background-color: #000000;
user-select: none; user-select: none;
} }
@@ -132,7 +136,11 @@ footer {
-ms-overflow-style: none; -ms-overflow-style: none;
padding: 10px; padding: 10px;
height: 10px; height: 10px;
background: #000000; }
.chat-messages {
display: flex;
flex-direction: column;
} }
.container { .container {
@@ -151,9 +159,11 @@ footer {
} }
} }
.chat-messages picture img { .chat-messages picture img {
cursor: pointer; cursor: pointer;
} }
.chat-messages::-webkit-scrollbar { .chat-messages::-webkit-scrollbar {
display: none; display: none;
} }
@@ -230,15 +240,14 @@ footer {
.chat-input { .chat-input {
padding: 15px; padding: 15px;
background-color: #000000;
display: flex; display: flex;
align-items: center; align-items: center;
} }
input[type="text"], .chat-input textarea { input[type="text"], .chat-input textarea {
flex: 1; flex: 1;
background-color: #000000;
color: white; color: white;
background: none;
border: none; border: none;
padding: 10px; padding: 10px;
border-radius: 5px; border-radius: 5px;
@@ -343,11 +352,11 @@ a {
.sidebar { .sidebar {
width: 250px; width: 250px;
background-color: #000000;
padding-left: 20px; padding-left: 20px;
padding-right: 20px; padding-right: 20px;
padding-top: 10px; padding-top: 10px;
overflow-y: auto; overflow-y: auto;
grid-area: sidebar;
} }
.sidebar h2 { .sidebar h2 {
@@ -392,7 +401,6 @@ a {
} }
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
header { header {
@@ -402,17 +410,21 @@ a {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.logo { .logo {
display: block; display: block;
flex: 1; flex: 1;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
h2 { h2 {
font-size: 14px; font-size: 14px;
} }
text-align: center; text-align: center;
} }
nav { nav {
text-align: right; text-align: right;
flex: 1; flex: 1;
@@ -494,13 +506,23 @@ dialog .dialog-button {
@keyframes dialogFadeIn { @keyframes dialogFadeIn {
from { opacity: 0; } from {
to { opacity: 1; } opacity: 0;
}
to {
opacity: 1;
}
} }
@keyframes dialogScaleIn { @keyframes dialogScaleIn {
from { transform: scale(0.95) translate(-50%, -50%); opacity: 0; } from {
to { transform: scale(1) translate(-50%, -50%); opacity: 1; } transform: scale(0.95) translate(-50%, -50%);
opacity: 0;
}
to {
transform: scale(1) translate(-50%, -50%);
opacity: 1;
}
} }
dialog .dialog-button.primary { dialog .dialog-button.primary {
@@ -522,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;
}
+73 -35
View File
@@ -4,13 +4,23 @@ class ChatInputComponent extends HTMLElement {
autoCompletions = { autoCompletions = {
"example 1": () => {}, "example 1": () => {},
"example 2": () => {}, "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() { constructor() {
super(); super();
this.lastUpdateEvent = new Date(); this.lastUpdateEvent = new Date();
this.textarea = document.createElement("textarea"); this.textarea = document.createElement("textarea");
this._value = "";
this.value = this.getAttribute("value") || ""; this.value = this.getAttribute("value") || "";
this.previousValue = this.value; this.previousValue = this.value;
this.lastChange = new Date(); this.lastChange = new Date();
@@ -25,12 +35,15 @@ class ChatInputComponent extends HTMLElement {
this._value = value || ""; this._value = value || "";
this.textarea.value = this._value; this.textarea.value = this._value;
} }
get allAutoCompletions() {
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
}
resolveAutoComplete() { resolveAutoComplete() {
let count = 0; let count = 0;
let value = null; 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++; count++;
value = key; value = key;
} }
@@ -47,16 +60,16 @@ class ChatInputComponent extends HTMLElement {
this.textarea.focus(); this.textarea.focus();
} }
getAuthors(){ getAuthors(){
const uniqueAuthors = new Set() let authors = []
document.querySelectorAll('div.author').forEach(el=> for (let i = 0; i < this.users.length; i++) {
{ authors.push(this.users[i].username)
uniqueAuthors.add(el.innerText.trim()) authors.push(this.users[i].nick)
}) }
return authors
return Array.from(uniqueAuthors)
} }
extractMentions(text) { extractMentions(text) {
const regex = /@([a-zA-Z0-9_]+)/g; const regex = /@([a-zA-Z0-9_-]+)/g;
const mentions = []; const mentions = [];
let match; let match;
@@ -70,13 +83,23 @@ class ChatInputComponent extends HTMLElement {
return mentions.map(mention => { return mentions.map(mention => {
let closestAuthor = null; let closestAuthor = null;
let minDistance = Infinity; let minDistance = Infinity;
const lowerMention = mention.toLowerCase();
authors.forEach(author => { 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) { if (distance < minDistance) {
minDistance = distance; minDistance = distance;
closestAuthor = author; closestAuthor = author;
} }
}); });
return { mention, closestAuthor, distance: minDistance }; return { mention, closestAuthor, distance: minDistance };
@@ -118,11 +141,9 @@ levenshteinDistance(a, b) {
const mentions = this.extractMentions(text); const mentions = this.extractMentions(text);
const matches = this.matchMentionsToAuthors(mentions, authors); const matches = this.matchMentionsToAuthors(mentions, authors);
console.info(matches)
let updatedText = text; let updatedText = text;
matches.forEach(({ mention, closestAuthor }) => { matches.forEach(({ mention, closestAuthor }) => {
const mentionRegex = new RegExp(`@${mention}`, 'g'); const mentionRegex = new RegExp(`@${mention}`, 'g');
console.info(closestAuthor)
updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`); updatedText = updatedText.replace(mentionRegex, `@${closestAuthor}`);
}); });
@@ -136,11 +157,17 @@ levenshteinDistance(a, b) {
app.rpc.getUser(null).then((user) => { app.rpc.getUser(null).then((user) => {
this.user=user this.user=user
}) })
const me = this; const me = this;
this.liveType = this.getAttribute("live-type") === "true"; this.liveType = this.getAttribute("live-type") === "true";
this.liveTypeInterval = this.liveTypeInterval =
parseInt(this.getAttribute("live-type-interval")) || 3; parseInt(this.getAttribute("live-type-interval")) || 6;
this.channelUid = this.getAttribute("channel"); this.channelUid = this.getAttribute("channel");
app.rpc.getRecentUsers(this.channelUid).then(users=>{
this.users = users
})
this.messageUid = null; this.messageUid = null;
this.classList.add("chat-input"); this.classList.add("chat-input");
@@ -174,9 +201,10 @@ levenshteinDistance(a, b) {
this.textarea.addEventListener("keydown", (e) => { this.textarea.addEventListener("keydown", (e) => {
this.value = e.target.value; this.value = e.target.value;
let autoCompletion = null;
if (e.key === "Tab") { if (e.key === "Tab") {
e.preventDefault(); e.preventDefault();
let autoCompletion = this.resolveAutoComplete(); autoCompletion = this.resolveAutoComplete();
if (autoCompletion) { if (autoCompletion) {
e.target.value = autoCompletion; e.target.value = autoCompletion;
this.value = autoCompletion; this.value = autoCompletion;
@@ -187,29 +215,26 @@ levenshteinDistance(a, b) {
e.preventDefault(); e.preventDefault();
const message = me.replaceMentionsWithAuthors(this.value); const message = me.replaceMentionsWithAuthors(this.value);
this.messageUid = null; e.target.value = "";
this.value = "";
this.previousValue = "";
if (!message) { if (!message) {
return; return;
} }
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
let autoCompletion = this.autoCompletions[message]; if (autoCompletionHandler) {
if (autoCompletion) { autoCompletionHandler();
this.value = ""; this.value = "";
this.previousValue = ""; this.previousValue = "";
e.target.value = ""; e.target.value = "";
autoCompletion();
return; return;
} }
e.target.value = ""; this.updateMessage()
app.rpc.finalizeMessage(this.messageUid)
this.value = ""; this.value = "";
this.previousValue = "";
this.messageUid = null; this.messageUid = null;
this.sendMessage(this.channelUid, message).then((uid) => {
this.messageUid = uid;
});
} }
}); });
@@ -238,7 +263,7 @@ levenshteinDistance(a, b) {
e.detail.files.forEach((file) => { e.detail.files.forEach((file) => {
message += `[${file.name}](/channel/attachment/${file.relative_url})`; message += `[${file.name}](/channel/attachment/${file.relative_url})`;
}); });
app.rpc.sendMessage(this.channelUid, message); app.rpc.sendMessage(this.channelUid, message,true);
}); });
setTimeout(()=>{ setTimeout(()=>{
this.focus(); this.focus();
@@ -249,14 +274,28 @@ levenshteinDistance(a, b) {
const millisecondsDifference = event2Time.getTime() - event1Time.getTime(); const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
return millisecondsDifference / 1000; 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() { newMessage() {
if (!this.messageUid) { if (!this.messageUid) {
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; this.messageUid = uid;
}
}); });
} }
@@ -276,8 +315,7 @@ levenshteinDistance(a, b) {
app.rpc && app.rpc &&
typeof app.rpc.updateMessageText === "function" typeof app.rpc.updateMessageText === "function"
) { ) {
this.value = this.replaceMentionsWithAuthors(this.value); app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
app.rpc.updateMessageText(this.messageUid, this.value);
} }
} }
@@ -324,11 +362,11 @@ levenshteinDistance(a, b) {
} }
} }
async sendMessage(channelUid, value) { async sendMessage(channelUid, value,is_final) {
if (!value.trim()) { if (!value.trim()) {
return null; 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 = []; 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) { scrollToBottom(force) {
console.info("Scrolling down")
// if (force) {
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView(); this.querySelector(".message-list-bottom").scrollIntoView();
@@ -29,7 +59,6 @@ class MessageList extends HTMLElement {
this.scrollTop = this.scrollHeight; this.scrollTop = this.scrollHeight;
this.querySelector(".message-list-bottom").scrollIntoView(); this.querySelector(".message-list-bottom").scrollIntoView();
},200) },200)
// }
} }
updateMessageText(uid, message) { updateMessageText(uid, message) {
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]'); const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
@@ -37,12 +66,15 @@ class MessageList extends HTMLElement {
if (!messageDiv) { if (!messageDiv) {
return; return;
} }
const scrollToBottom = this.isScrolledToBottom();
const receivedHtml = document.createElement("div"); const receivedHtml = document.createElement("div");
receivedHtml.innerHTML = message.html; receivedHtml.innerHTML = message.html;
const html = receivedHtml.querySelector(".text").innerHTML; const html = receivedHtml.querySelector(".text").innerHTML;
const textElement = messageDiv.querySelector(".text"); const textElement = messageDiv.querySelector(".text");
textElement.innerHTML = html; textElement.innerHTML = html;
textElement.style.display = message.text == "" ? "none" : "block"; textElement.style.display = message.text == "" ? "none" : "block";
if(scrollToBottom)
this.scrollToBottom(true)
} }
triggerGlow(uid,color) { triggerGlow(uid,color) {
app.starField.glowColor(color) app.starField.glowColor(color)
+1
View File
@@ -25,6 +25,7 @@
border-radius: 50%; border-radius: 50%;
background-color: var(--star-color); background-color: var(--star-color);
animation: twinkle 2s infinite ease-in-out; animation: twinkle 2s infinite ease-in-out;
z-index: -1;
} }
@keyframes twinkle { @keyframes twinkle {
+7
View File
@@ -17,6 +17,8 @@ export class Socket extends EventHandler {
shouldReconnect = true; shouldReconnect = true;
_debug = false;
get isConnected() { get isConnected() {
return this.ws && this.ws.readyState === WebSocket.OPEN; return this.ws && this.ws.readyState === WebSocket.OPEN;
} }
@@ -112,6 +114,11 @@ export class Socket extends EventHandler {
get(_, prop) { get(_, prop) {
return (...args) => { return (...args) => {
const functionName = me._camelToSnake(prop); const functionName = me._camelToSnake(prop);
if(me._debug){
const call = {}
call[functionName] = args
console.debug(call)
}
return me.call(functionName, ...args); return me.call(functionName, ...args);
}; };
}, },
+16 -8
View File
@@ -1,6 +1,6 @@
DEFAULT_LIMIT = 30 DEFAULT_LIMIT = 30
import typing import typing
import asyncio
from snek.system.model import BaseModel from snek.system.model import BaseModel
@@ -19,6 +19,13 @@ class BaseMapper:
def db(self): def db(self):
return self.app.db 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): async def new(self):
return self.model_class(mapper=self, app=self.app) return self.model_class(mapper=self, app=self.app)
@@ -29,7 +36,8 @@ class BaseMapper:
async def get(self, uid: str = None, **kwargs) -> BaseModel: async def get(self, uid: str = None, **kwargs) -> BaseModel:
if uid: if uid:
kwargs["uid"] = uid kwargs["uid"] = uid
record = self.table.find_one(**kwargs)
record = await self.run_in_executor(self.table.find_one,**kwargs)
if not record: if not record:
return None return None
record = dict(record) record = dict(record)
@@ -40,31 +48,31 @@ class BaseMapper:
return await self.model_class.from_record(mapper=self, record=record) return await self.model_class.from_record(mapper=self, record=record)
async def exists(self, **kwargs): 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: 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: async def save(self, model: BaseModel) -> bool:
if not model.record.get("uid"): if not model.record.get("uid"):
raise Exception(f"Attempt to save without uid: {model.record}.") raise Exception(f"Attempt to save without uid: {model.record}.")
model.updated_at.update() 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: async def find(self, **kwargs) -> typing.AsyncGenerator:
if not kwargs.get("_limit"): if not kwargs.get("_limit"):
kwargs["_limit"] = self.default_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() model = await self.new()
for key, value in record.items(): for key, value in record.items():
model[key] = value model[key] = value
yield model yield model
async def query(self, sql, *args): 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) yield dict(record)
async def delete(self, **kwargs) -> int: async def delete(self, **kwargs) -> int:
if not kwargs or not isinstance(kwargs, dict): if not kwargs or not isinstance(kwargs, dict):
raise Exception("Can't execute delete with no filter.") 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 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 emoji
import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from jinja2 import TemplateSyntaxError, nodes from jinja2 import TemplateSyntaxError, nodes
from jinja2.ext import Extension from jinja2.ext import Extension
@@ -231,6 +233,153 @@ def linkify_https(text):
return set_link_target_blank(str(soup)) 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): class EmojiExtension(Extension):
tags = {"emoji"} tags = {"emoji"}
@@ -276,6 +425,9 @@ class LinkifyExtension(Extension):
result = embed_youtube(result) result = embed_youtube(result)
result = enrich_image_rendering(result) result = enrich_image_rendering(result)
result = embed_url(result)
return result return result
+3 -2
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <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" /> <link rel="manifest" href="/manifest.json" />
<title>Snek</title> <title>Snek</title>
<style>{{highlight_styles}}</style> <style>{{highlight_styles}}</style>
@@ -50,10 +50,11 @@
</nav> </nav>
</header> </header>
<main>
{% block sidebar %} {% block sidebar %}
{% include "sidebar_channels.html" %} {% include "sidebar_channels.html" %}
{% endblock %} {% endblock %}
<main>
{% block main %} {% block main %}
<chat-window class="chat-area"></chat-window> <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="/html-frame.js" type="module"></script>
<script src="/generic-form.js?rid={{ rid }}" type="module"></script> <script src="/generic-form.js?rid={{ rid }}" type="module"></script>
<link rel="stylesheet" href="/html-frame.css"> <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 %} {% block head %}
{% endblock %} {% endblock %}
</head> </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>
+31 -32
View File
@@ -13,7 +13,7 @@
{% endfor %} {% endfor %}
<div class="message-list-bottom"></div> <div class="message-list-bottom"></div>
</message-list> </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> </section>
{% include "dialog_help.html" %} {% include "dialog_help.html" %}
{% include "dialog_online.html" %} {% include "dialog_online.html" %}
@@ -157,6 +157,10 @@ app.ws.addEventListener("refresh", (data) => {
app.starField.showNotify(data.message); app.starField.showNotify(data.message);
setTimeout(() => window.location.reload(), 4000); 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.ws.addEventListener("deployed", (data) => {
app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 }); app.starField.renderWord("Deployed", { rainbow: true, resolution: 8 });
setTimeout(() => app.starField.shuffleAll(5000), 10000); setTimeout(() => app.starField.shuffleAll(5000), 10000);
@@ -167,7 +171,9 @@ app.ws.addEventListener("starfield.render_word", (data) => {
// --- Channel message event --- // --- Channel message event ---
app.addEventListener("channel-message", (data) => { app.addEventListener("channel-message", (data) => {
let display = data.text && data.text.trim() ? 'block' : 'none'; let display = data.text && data.text.trim() ? 'block' : 'none';
if (data.channel_uid !== channelUid) { if (data.channel_uid !== channelUid) {
if (!isMentionForSomeoneElse(data.message)) { if (!isMentionForSomeoneElse(data.message)) {
channelSidebar.notify(data); channelSidebar.notify(data);
@@ -179,13 +185,22 @@ app.addEventListener("channel-message", (data) => {
if (isMentionToMe(data.message)) { if (isMentionToMe(data.message)) {
app.playSound("mention"); app.playSound("mention");
} else if (!isMentionForSomeoneElse(data.message)) { } else if (!isMentionForSomeoneElse(data.message)) {
if(data.is_final){
app.playSound("message"); app.playSound("message");
} }
} }
const lastMessage = messagesContainer.querySelector(".message:last-child"); }
const lastElement = messagesContainer.querySelector(".message-list-bottom"); 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"); const message = document.createElement("div");
message.innerHTML = data.html; message.innerHTML = data.html;
message.style.display = display; message.style.display = display;
messagesContainer.insertBefore(message.firstChild, lastElement); messagesContainer.insertBefore(message.firstChild, lastElement);
@@ -201,8 +216,6 @@ document.addEventListener('keydown', function(event) {
gPressCount = 0; gPressCount = 0;
document.activeElement.blur(); document.activeElement.blur();
} }
console.info(event.key)
console.info(escPressed)
if (event.key === 'g' && !chatInputField.isActive()) { if (event.key === 'g' && !chatInputField.isActive()) {
gPressCount++; gPressCount++;
clearTimeout(keyTimeout); clearTimeout(keyTimeout);
@@ -213,6 +226,18 @@ document.addEventListener('keydown', function(event) {
loadExtra(); 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 (event.shiftKey && event.key === 'G') {
if (!chatInputField.isActive()) { if (!chatInputField.isActive()) {
event.preventDefault(); event.preventDefault();
@@ -223,24 +248,7 @@ document.addEventListener('keydown', function(event) {
}); });
// --- Image click-to-zoom (delegated) --- // --- 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 --- // --- Layout update ---
let lastMessage;
function updateLayout(doScrollDown) { function updateLayout(doScrollDown) {
updateTimes(); updateTimes();
let previousUser = null, previousDate = null; let previousUser = null, previousDate = null;
@@ -264,20 +272,11 @@ function updateLayout(doScrollDown) {
} }
} }
}); });
lastMessage = messagesContainer.querySelector(".message:last-child");
if (doScrollDown) messagesContainer.scrollToBottom?.(); if (doScrollDown) messagesContainer.scrollToBottom?.();
} }
// --- Utility: check if element is visible --- // --- 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 --- // --- Utility: check if scrolled past half ---
function isScrolledPastHalf() { function isScrolledPastHalf() {
+21 -6
View File
@@ -41,23 +41,38 @@ class AvatarView(BaseView):
self.avatars = {} self.avatars = {}
async def get(self): 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") 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: while True:
try: 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: except Exception as e:
pass pass
def _get(self, uid): def _get(self, uid):
if uid in self.avatars:
return self.avatars[uid]
avatar = generate_avatar_with_options(self.request.query) avatar = generate_avatar_with_options(self.request.query)
self.avatars[uid] = avatar
return avatar return avatar
async def get2(self): async def get_default(self):
uid = self.request.match_info.get("uid") uid = self.request.match_info.get("uid")
if uid == "unique": if uid == "unique":
uid = str(uuid.uuid4()) 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, "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 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): async def update_message_text(self,message_uid, text):
self._require_login() self._require_login()
message = await self.services.channel_message.get(message_uid) message = await self.services.channel_message.get(message_uid)
@@ -200,6 +215,7 @@ class RPCView(BaseView):
raise Exception("Not allowed") raise Exception("Not allowed")
if message.get_seconds_since_last_update() > 3: 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} return {"error": "Message too old","seconds_since_last_update": message.get_seconds_since_last_update(),"success": False}
message['message'] = text message['message'] = text
@@ -221,9 +237,9 @@ class RPCView(BaseView):
return {"success": True} 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() 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"] return message["uid"]
@@ -314,19 +330,28 @@ class RPCView(BaseView):
self._require_login() self._require_login()
results = [ 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: results = sorted(results, key=lambda x: x["nick"])
del result['email']
del result['password']
del result['deleted_at']
del result['updated_at']
return results return results
async def echo(self, obj): async def echo(self, obj):
await self.ws.send_json(obj) await self.ws.send_json(obj)
return "noresponse" 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): async def get_users(self, channel_uid):
self._require_login() self._require_login()
@@ -355,7 +380,13 @@ class RPCView(BaseView):
return {"pong": args} 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): async def get(self):
scheduled = [] scheduled = []
@@ -382,7 +413,7 @@ class RPCView(BaseView):
"message": "Finishing deployment"} "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} "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"] user_uid=self.session.get("uid"), channel_uid=channel["uid"]
) )
if not channel_member: 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() return web.HTTPNotFound()
channel_member["new_count"] = 0 channel_member["new_count"] = 0