Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bde3819510 | ||
|
|
097889ba3f | ||
|
|
4854d40508 | ||
|
|
7dd3133475 | ||
|
|
24dfa39f91 | ||
|
|
7ec65f7c12 | ||
|
|
4f8edef42b | ||
|
|
7818410d55 | ||
|
|
1762191b03 | ||
|
|
2df92e809e | ||
|
|
59a8d32e40 | ||
|
|
c3b3963760 | ||
|
|
a0cd39e3bc | ||
|
|
e48b2258e0 | ||
|
|
35aaf8824f | ||
|
|
76c69ca3ec | ||
|
|
9994225911 | ||
|
|
03f699e448 | ||
|
|
2fd01a5ab7 | ||
|
|
96629113f1 | ||
|
|
973afa0cc2 | ||
|
|
9bc55e771a | ||
|
|
40a292d05e | ||
|
|
bf723db2cc | ||
|
|
46052172b2 | ||
|
|
4f777f0003 | ||
|
|
b5e1ba72d0 | ||
|
|
df120098f9 | ||
|
|
1c1d578db7 | ||
|
|
69352fe0b5 | ||
|
|
fb3980dad0 | ||
|
|
6c21a1e619 | ||
|
|
112c0dc70a | ||
|
|
538a9ce25d | ||
|
|
cdc3d10df5 | ||
|
|
1b150e3e64 | ||
|
|
27dccc324a | ||
|
|
1bb68ab33b | ||
|
|
3c6ea15d47 | ||
|
|
36e663e1ed | ||
|
|
8d2e0381a7 | ||
|
|
f67d7b35f1 | ||
|
|
9ec62f7471 | ||
|
|
6bbbc41360 | ||
|
|
d3844ac7a7 | ||
|
|
9378e95a5b | ||
|
|
5e4c4ce228 | ||
|
|
ffc373db62 | ||
|
|
60266bf0dc | ||
|
|
8393a80022 | ||
|
|
234edf4756 | ||
|
|
5fd401bfb6 | ||
|
|
5663a5f376 | ||
|
|
81327a9e20 | ||
|
|
2a5b9ad276 | ||
|
|
662e71c621 | ||
|
|
636adfd997 | ||
|
|
b94f7a9532 | ||
|
|
f954a34384 | ||
|
|
6a74263606 | ||
|
|
0bf714061c | ||
|
|
e4e2e919c2 | ||
|
|
7fe4289f42 | ||
|
|
3ce866b7da | ||
|
|
43982c16fa | ||
|
|
e33e4196ab | ||
|
|
2c506db4e4 | ||
|
|
9b9d356849 | ||
|
|
30b7871583 | ||
|
|
1c873b7d02 | ||
|
|
539fb262b2 | ||
|
|
3bf09f9083 | ||
|
|
a55d15b635 | ||
|
|
431748c489 | ||
|
|
a11c336cf5 | ||
|
|
6b083f8b1b | ||
|
|
89afbba165 |
-1489
File diff suppressed because one or more lines are too long
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
+35
-1
@@ -3,6 +3,7 @@ import logging
|
|||||||
import pathlib
|
import pathlib
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
from snek import snode
|
from snek import snode
|
||||||
from snek.view.threads import ThreadsView
|
from snek.view.threads import ThreadsView
|
||||||
import json
|
import json
|
||||||
@@ -55,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
|
||||||
@@ -96,6 +98,7 @@ class Application(BaseApplication):
|
|||||||
self.jinja2_env.add_extension(LinkifyExtension)
|
self.jinja2_env.add_extension(LinkifyExtension)
|
||||||
self.jinja2_env.add_extension(PythonExtension)
|
self.jinja2_env.add_extension(PythonExtension)
|
||||||
self.jinja2_env.add_extension(EmojiExtension)
|
self.jinja2_env.add_extension(EmojiExtension)
|
||||||
|
self.time_start = datetime.now()
|
||||||
self.ssh_host = "0.0.0.0"
|
self.ssh_host = "0.0.0.0"
|
||||||
self.ssh_port = 2242
|
self.ssh_port = 2242
|
||||||
self.setup_router()
|
self.setup_router()
|
||||||
@@ -113,6 +116,33 @@ class Application(BaseApplication):
|
|||||||
self.on_startup.append(self.start_ssh_server)
|
self.on_startup.append(self.start_ssh_server)
|
||||||
self.on_startup.append(self.prepare_database)
|
self.on_startup.append(self.prepare_database)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uptime_seconds(self):
|
||||||
|
return (datetime.now() - self.time_start).total_seconds()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uptime(self):
|
||||||
|
return self._format_uptime(self.uptime_seconds)
|
||||||
|
|
||||||
|
def _format_uptime(self,seconds):
|
||||||
|
seconds = int(seconds)
|
||||||
|
days, seconds = divmod(seconds, 86400)
|
||||||
|
hours, seconds = divmod(seconds, 3600)
|
||||||
|
minutes, seconds = divmod(seconds, 60)
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
if days > 0:
|
||||||
|
parts.append(f"{days} day{'s' if days != 1 else ''}")
|
||||||
|
if hours > 0:
|
||||||
|
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
|
||||||
|
if minutes > 0:
|
||||||
|
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
|
||||||
|
if seconds > 0 or not parts:
|
||||||
|
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
|
||||||
|
|
||||||
|
return ", ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
async def start_user_availability_service(self, app):
|
async def start_user_availability_service(self, app):
|
||||||
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
|
app.user_availability_service_task = asyncio.create_task(app.services.socket.user_availability_service())
|
||||||
async def snode_sync(self, app):
|
async def snode_sync(self, app):
|
||||||
@@ -192,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)
|
||||||
@@ -248,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:
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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}")
|
||||||
|
|||||||
+15
-7
@@ -160,11 +160,15 @@ export class App extends EventHandler {
|
|||||||
typeLock = null;
|
typeLock = null;
|
||||||
typeListener = null;
|
typeListener = null;
|
||||||
typeEventChannelUid = null;
|
typeEventChannelUid = null;
|
||||||
async set_typing(channel_uid) {
|
_debug = false
|
||||||
|
async set_typing(channel_uid) {
|
||||||
this.typeEventChannel_uid = channel_uid;
|
this.typeEventChannel_uid = channel_uid;
|
||||||
}
|
}
|
||||||
|
debug() {
|
||||||
async ping(...args) {
|
this._debug = !this._debug;
|
||||||
|
this.ws._debug = this._debug;
|
||||||
|
}
|
||||||
|
async ping(...args) {
|
||||||
if (this.is_pinging) return false;
|
if (this.is_pinging) return false;
|
||||||
this.is_pinging = true;
|
this.is_pinging = true;
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
@@ -174,7 +178,7 @@ export class App extends EventHandler {
|
|||||||
async forcePing(...arg) {
|
async forcePing(...arg) {
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
}
|
}
|
||||||
|
starField = null
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.ws = new Socket();
|
this.ws = new Socket();
|
||||||
@@ -196,12 +200,16 @@ export class App extends EventHandler {
|
|||||||
this.ws.addEventListener("connected", (data) => {
|
this.ws.addEventListener("connected", (data) => {
|
||||||
this.ping("online");
|
this.ping("online");
|
||||||
});
|
});
|
||||||
|
this.ws.addEventListener("reconnecting", (data) => {
|
||||||
|
this.starField?.showNotify("Connecting..","#CC0000")
|
||||||
|
})
|
||||||
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;
|
||||||
|
|||||||
+144
-80
@@ -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,33 +29,35 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
header .logo {
|
header .logo {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,13 +87,13 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
color: #f05a28;
|
color: #f05a28;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.4em;
|
font-size: 1.4em;
|
||||||
color: #f05a28;
|
color: #f05a28;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,10 +116,10 @@ h2 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-list {
|
.message-list {
|
||||||
@@ -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;
|
||||||
@@ -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 {
|
.time {
|
||||||
display: block;
|
display: block;
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,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 {
|
||||||
@@ -375,65 +385,68 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes glow {
|
@keyframes glow {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 5px #3498db;
|
box-shadow: 0 0 5px #3498db;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
|
box-shadow: 0 0 20px #3498db, 0 0 30px #3498db;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 5px #3498db;
|
box-shadow: 0 0 5px #3498db;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.glow {
|
.glow {
|
||||||
animation: glow 1s;
|
animation: glow 1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@media only screen and (max-width: 768px) {
|
@media only screen and (max-width: 768px) {
|
||||||
|
|
||||||
header{
|
header {
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
width:100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
.logo {
|
|
||||||
display:block;
|
.logo {
|
||||||
flex: 1;
|
display: block;
|
||||||
text-overflow: ellipsis;
|
flex: 1;
|
||||||
white-space: nowrap;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
white-space: nowrap;
|
||||||
h2 {
|
overflow: hidden;
|
||||||
font-size: 14px;
|
|
||||||
}
|
h2 {
|
||||||
text-align: center;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
nav {
|
|
||||||
text-align: right;
|
text-align: center;
|
||||||
flex: 1;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
nav {
|
||||||
body {
|
text-align: right;
|
||||||
justify-content: flex-start;
|
flex: 1;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
header{
|
}
|
||||||
position: sticky;
|
|
||||||
display: block;
|
/*
|
||||||
.logo {
|
body {
|
||||||
display:block;
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
}
|
header{
|
||||||
.chat-input {
|
position: sticky;
|
||||||
position:sticky;
|
display: block;
|
||||||
}*/
|
.logo {
|
||||||
|
display:block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.chat-input {
|
||||||
|
position:sticky;
|
||||||
|
}*/
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
@@ -493,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 {
|
||||||
@@ -512,12 +535,53 @@ dialog .dialog-button.primary:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dialog .dialog-button.secondary {
|
dialog .dialog-button.secondary {
|
||||||
background-color: #f0a328;
|
background-color: #f0a328;
|
||||||
color: #eee;
|
color: #eee;
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog .dialog-button.secondary:hover {
|
dialog .dialog-button.secondary:hover {
|
||||||
background-color: #f0b84c;
|
background-color: #f0b84c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
+160
-30
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -46,14 +59,116 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
focus() {
|
focus() {
|
||||||
this.textarea.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() {
|
async connectedCallback() {
|
||||||
this.user = await app.rpc.getUser(null);
|
this.user = null
|
||||||
|
app.rpc.getUser(null).then((user) => {
|
||||||
|
this.user=user
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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");
|
||||||
this.messageUid = null;
|
|
||||||
|
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
||||||
|
this.users = users
|
||||||
|
})
|
||||||
|
this.messageUid = null;
|
||||||
|
|
||||||
this.classList.add("chat-input");
|
this.classList.add("chat-input");
|
||||||
|
|
||||||
@@ -86,9 +201,10 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
|
|
||||||
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;
|
||||||
@@ -98,30 +214,27 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const message = e.target.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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,24 +263,41 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
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(()=>{
|
||||||
|
this.focus();
|
||||||
|
},1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
trackSecondsBetweenEvents(event1Time, event2Time) {
|
trackSecondsBetweenEvents(event1Time, event2Time) {
|
||||||
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.messageUid = uid;
|
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
|
||||||
|
if (this.liveType) {
|
||||||
|
this.messageUid = uid;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
updateMessage() {
|
updateMessage() {
|
||||||
if (this.value[0] == "/") {
|
if (this.value[0] == "/") {
|
||||||
@@ -185,7 +315,7 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
app.rpc &&
|
app.rpc &&
|
||||||
typeof app.rpc.updateMessageText === "function"
|
typeof app.rpc.updateMessageText === "function"
|
||||||
) {
|
) {
|
||||||
app.rpc.updateMessageText(this.messageUid, this.value);
|
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,11 +362,11 @@ class ChatInputComponent extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,47 @@ class MessageList extends HTMLElement {
|
|||||||
|
|
||||||
this.items = [];
|
this.items = [];
|
||||||
}
|
}
|
||||||
scrollToBottom(force) {
|
|
||||||
|
|
||||||
// if (force) {
|
connectedCallback() {
|
||||||
// this.scrollTop = this.scrollHeight;
|
const messagesContainer = this
|
||||||
this.querySelector(".message-list-bottom").scrollIntoView();
|
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) {
|
||||||
|
this.scrollTop = this.scrollHeight;
|
||||||
|
|
||||||
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
|
setTimeout(() => {
|
||||||
|
|
||||||
|
this.scrollTop = this.scrollHeight;
|
||||||
|
this.querySelector(".message-list-bottom").scrollIntoView();
|
||||||
|
},200)
|
||||||
}
|
}
|
||||||
updateMessageText(uid, message) {
|
updateMessageText(uid, message) {
|
||||||
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
||||||
@@ -31,15 +66,19 @@ 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) {
|
triggerGlow(uid,color) {
|
||||||
let lastElement = null;
|
app.starField.glowColor(color)
|
||||||
|
let lastElement = null;
|
||||||
this.querySelectorAll(".avatar").forEach((el) => {
|
this.querySelectorAll(".avatar").forEach((el) => {
|
||||||
const div = el.closest("a");
|
const div = el.closest("a");
|
||||||
if (el.href.indexOf(uid) != -1) {
|
if (el.href.indexOf(uid) != -1) {
|
||||||
|
|||||||
+175
-38
@@ -1,42 +1,179 @@
|
|||||||
|
:root {
|
||||||
|
--star-color: white;
|
||||||
|
--background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
.star {
|
body.day {
|
||||||
|
--star-color: #444;
|
||||||
|
--background-color: #e6f0ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.night {
|
||||||
|
--star-color: white;
|
||||||
|
--background-color: black;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
transition: background-color 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--star-color);
|
||||||
|
animation: twinkle 2s infinite ease-in-out;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes twinkle {
|
||||||
|
0%, 100% { opacity: 0.8; transform: scale(1); }
|
||||||
|
50% { opacity: 1; transform: scale(1.2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
#themeToggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star.special {
|
||||||
|
box-shadow: 0 0 10px 3px gold;
|
||||||
|
transform: scale(1.4);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 2px;
|
font-size: 12px;
|
||||||
height: 2px;
|
color: white;
|
||||||
background: var(--star-color, #fff);
|
|
||||||
border-radius: 50%;
|
|
||||||
opacity: 0;
|
|
||||||
transition: background 0.5s ease;
|
|
||||||
animation: twinkle ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes twinkle {
|
|
||||||
0%, 100% { opacity: 0; }
|
|
||||||
50% { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes star-glow-frames {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 5px --star-color;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
box-shadow: 0 0 20px --star-color, 0 0 30px --star-color;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 5px --star-color;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.star-glow {
|
|
||||||
animation: star-glow-frames 1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
color: var(--star-content-color, #eee);
|
|
||||||
font-family: sans-serif;
|
font-family: sans-serif;
|
||||||
text-align: center;
|
pointer-events: none;
|
||||||
top: 40%;
|
z-index: 9999;
|
||||||
transform: translateY(-40%);
|
white-space: nowrap;
|
||||||
|
text-shadow: 1px 1px 2px black;
|
||||||
|
display: none;
|
||||||
|
padding: 2px 6px;
|
||||||
}
|
}
|
||||||
|
.star-popup {
|
||||||
|
position: absolute;
|
||||||
|
max-width: 300px;
|
||||||
|
color: #fff;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
z-index: 10000;
|
||||||
|
text-shadow: 1px 1px 3px black;
|
||||||
|
display: none;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.star:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-popup {
|
||||||
|
position: absolute;
|
||||||
|
max-width: 300px;
|
||||||
|
background: white;
|
||||||
|
color: black;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
|
||||||
|
z-index: 10000;
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-popup h3 {
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-popup button {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 3em;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
font-weight: 300;
|
||||||
|
text-align: center;
|
||||||
|
text-shadow: 0 0 20px rgba(0,0,0,0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.6s ease;
|
||||||
|
max-width: 80vw;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes demoFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -60%) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes demoPulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
box-shadow: 0 0 30px 15px rgba(255, 255, 150, 0.9);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 rgba(255, 255, 150, 0);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-highlight {
|
||||||
|
animation: demoPulse 1.5s ease-out;
|
||||||
|
font-weight: bold;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-notify-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 50px;
|
||||||
|
right: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star-notify {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 5px 10px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 300;
|
||||||
|
text-shadow: 0 0 10px rgba(0,0,0,0.7);
|
||||||
|
transition: opacity 0.5s ease, transform 0.5s ease;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
font-family: 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,8 @@ export class Socket extends EventHandler {
|
|||||||
if (this.shouldReconnect)
|
if (this.shouldReconnect)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
console.log("Reconnecting");
|
console.log("Reconnecting");
|
||||||
return this.connect();
|
this.emit("reconnecting");
|
||||||
|
return this.connect();
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +114,12 @@ export class Socket extends EventHandler {
|
|||||||
get(_, prop) {
|
get(_, prop) {
|
||||||
return (...args) => {
|
return (...args) => {
|
||||||
const functionName = me._camelToSnake(prop);
|
const functionName = me._camelToSnake(prop);
|
||||||
return me.call(functionName, ...args);
|
if(me._debug){
|
||||||
|
const call = {}
|
||||||
|
call[functionName] = args
|
||||||
|
console.debug(call)
|
||||||
|
}
|
||||||
|
return me.call(functionName, ...args);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+157
-5
@@ -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
|
||||||
@@ -118,7 +120,7 @@ def embed_youtube(text):
|
|||||||
queries["start"] = []
|
queries["start"] = []
|
||||||
for t in start_time:
|
for t in start_time:
|
||||||
if t.endswith("s"):
|
if t.endswith("s"):
|
||||||
t = start_time[:-1]
|
t = t[:-1]
|
||||||
if t.isdigit():
|
if t.isdigit():
|
||||||
queries["start"].append(t)
|
queries["start"].append(t)
|
||||||
else:
|
else:
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
<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>
|
||||||
|
|||||||
+373
-485
@@ -1,504 +1,392 @@
|
|||||||
|
<div id="star-tooltip" class="star-tooltip"></div>
|
||||||
|
<div id="star-popup" class="star-popup"></div>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { app } from "/app.js";
|
import { app } from "/app.js";
|
||||||
|
|
||||||
const STAR_COUNT = 200;
|
|
||||||
const body = document.body;
|
|
||||||
|
|
||||||
function getStarPosition(star) {
|
|
||||||
const leftPercent = parseFloat(star.style.left);
|
|
||||||
const topPercent = parseFloat(star.style.top);
|
|
||||||
|
|
||||||
let position;
|
|
||||||
|
|
||||||
if (topPercent < 40 && leftPercent >= 40 && leftPercent <= 60) {
|
|
||||||
position = 'North';
|
|
||||||
} else if (topPercent > 60 && leftPercent >= 40 && leftPercent <= 60) {
|
|
||||||
position = 'South';
|
|
||||||
} else if (leftPercent < 40 && topPercent >= 40 && topPercent <= 60) {
|
|
||||||
position = 'West';
|
|
||||||
} else if (leftPercent > 60 && topPercent >= 40 && topPercent <= 60) {
|
|
||||||
position = 'East';
|
|
||||||
} else if (topPercent >= 40 && topPercent <= 60 && leftPercent >= 40 && leftPercent <= 60) {
|
|
||||||
position = 'Center';
|
|
||||||
} else {
|
|
||||||
position = 'Corner or Edge';
|
|
||||||
}
|
|
||||||
return position
|
|
||||||
}
|
|
||||||
let stars = {}
|
|
||||||
window.stars = stars
|
|
||||||
|
|
||||||
|
|
||||||
function createStar() {
|
|
||||||
const star = document.createElement('div');
|
|
||||||
star.classList.add('star');
|
|
||||||
star.style.left = `${Math.random() * 100}%`;
|
|
||||||
star.style.top = `${Math.random() * 100}%`;
|
|
||||||
star.shuffle = () => {
|
|
||||||
star.style.left = `${Math.random() * 100}%`;
|
|
||||||
star.style.top = `${Math.random() * 100}%`;
|
|
||||||
|
|
||||||
star.position = getStarPosition(star)
|
|
||||||
}
|
|
||||||
star.position = getStarPosition(star)
|
|
||||||
|
|
||||||
function moveStarToPosition(star, position) {
|
|
||||||
let top, left;
|
|
||||||
|
|
||||||
switch (position) {
|
|
||||||
case 'North':
|
|
||||||
top = `${Math.random() * 20}%`;
|
|
||||||
left = `${40 + Math.random() * 20}%`;
|
|
||||||
break;
|
|
||||||
case 'South':
|
|
||||||
top = `${80 + Math.random() * 10}%`;
|
|
||||||
left = `${40 + Math.random() * 20}%`;
|
|
||||||
break;
|
|
||||||
case 'West':
|
|
||||||
top = `${40 + Math.random() * 20}%`;
|
|
||||||
left = `${Math.random() * 20}%`;
|
|
||||||
break;
|
|
||||||
case 'East':
|
|
||||||
top = `${40 + Math.random() * 20}%`;
|
|
||||||
left = `${80 + Math.random() * 10}%`;
|
|
||||||
break;
|
|
||||||
case 'Center':
|
|
||||||
top = `${45 + Math.random() * 10}%`;
|
|
||||||
left = `${45 + Math.random() * 10}%`;
|
|
||||||
break;
|
|
||||||
default: // 'Corner or Edge' fallback
|
|
||||||
top = `${Math.random() * 100}%`;
|
|
||||||
left = `${Math.random() * 100}%`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
star.style.top = top;
|
|
||||||
star.style.left = left;
|
|
||||||
|
|
||||||
star.position = getStarPosition(star)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if(!stars[star.position])
|
|
||||||
stars[star.position] = []
|
|
||||||
stars[star.position].push(star)
|
|
||||||
const size = Math.random() * 2 + 1;
|
|
||||||
star.style.width = `${size}px`;
|
|
||||||
star.style.height = `${size}px`;
|
|
||||||
const duration = Math.random() * 3 + 2;
|
|
||||||
const delay = Math.random() * 5;
|
|
||||||
star.style.animationDuration = `${duration}s`;
|
|
||||||
star.style.animationDelay = `${delay}s`;
|
|
||||||
body.appendChild(star);
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.from({ length: STAR_COUNT }, createStar);
|
|
||||||
|
|
||||||
function lightenColor(hex, percent) {
|
|
||||||
const num = parseInt(hex.replace("#", ""), 16);
|
|
||||||
let r = (num >> 16) + Math.round(255 * percent / 100);
|
|
||||||
let g = ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100);
|
|
||||||
let b = (num & 0x0000FF) + Math.round(255 * percent / 100);
|
|
||||||
r = Math.min(255, r);
|
|
||||||
g = Math.min(255, g);
|
|
||||||
b = Math.min(255, b);
|
|
||||||
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const originalColor = document.documentElement.style.getPropertyValue("--star-color").trim();
|
|
||||||
|
|
||||||
function glowCSSVariable(varName, glowColor, duration = 500) {
|
|
||||||
const root = document.documentElement;
|
|
||||||
|
|
||||||
//igetComputedStyle(root).getPropertyValue(varName).trim();
|
|
||||||
glowColor = lightenColor(glowColor, 10);
|
|
||||||
root.style.setProperty(varName, glowColor);
|
|
||||||
setTimeout(() => {
|
|
||||||
root.style.setProperty(varName, originalColor);
|
|
||||||
}, duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStarColorDelayed(color) {
|
|
||||||
glowCSSVariable('--star-color', color, 2500);
|
|
||||||
}
|
|
||||||
app.updateStarColor = updateStarColorDelayed;
|
|
||||||
app.ws.addEventListener("set_typing", (data) => {
|
|
||||||
updateStarColorDelayed(data.color);
|
|
||||||
});
|
|
||||||
window.createAvatar = () => {
|
|
||||||
let avatar = document.createElement("avatar-face")
|
|
||||||
document.querySelector("main").appendChild(avatar)
|
|
||||||
return avatar
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarFace extends HTMLElement {
|
|
||||||
static get observedAttributes(){
|
|
||||||
return ['emotion','face-color','eye-color','text','balloon-color','text-color'];
|
|
||||||
}
|
|
||||||
constructor(){
|
|
||||||
super();
|
|
||||||
this._shadow = this.attachShadow({mode:'open'});
|
|
||||||
this._shadow.innerHTML = `
|
|
||||||
<style>
|
|
||||||
:host { display:block; position:relative; }
|
|
||||||
canvas { width:100%; height:100%; display:block; }
|
|
||||||
</style>
|
|
||||||
<canvas></canvas>
|
|
||||||
`;
|
|
||||||
this._c = this._shadow.querySelector('canvas');
|
|
||||||
this._ctx = this._c.getContext('2d');
|
|
||||||
|
|
||||||
// state
|
|
||||||
this._mouse = {x:0,y:0};
|
|
||||||
this._blinkTimer = 0;
|
|
||||||
this._blinking = false;
|
|
||||||
this._lastTime = 0;
|
|
||||||
|
|
||||||
// defaults
|
|
||||||
this._emotion = 'neutral';
|
|
||||||
this._faceColor = '#ffdfba';
|
|
||||||
this._eyeColor = '#000';
|
|
||||||
this._text = '';
|
|
||||||
this._balloonColor = '#fff';
|
|
||||||
this._textColor = '#000';
|
|
||||||
}
|
|
||||||
|
|
||||||
attributeChangedCallback(name,_old,newV){
|
|
||||||
if (name==='emotion') this._emotion = newV||'neutral';
|
|
||||||
else if (name==='face-color') this._faceColor = newV||'#ffdfba';
|
|
||||||
else if (name==='eye-color') this._eyeColor = newV||'#000';
|
|
||||||
else if (name==='text') this._text = newV||'';
|
|
||||||
else if (name==='balloon-color')this._balloonColor = newV||'#fff';
|
|
||||||
else if (name==='text-color') this._textColor = newV||'#000';
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback(){
|
|
||||||
// watch size so canvas buffer matches display
|
|
||||||
this._ro = new ResizeObserver(entries=>{
|
|
||||||
for(const ent of entries){
|
|
||||||
const w = ent.contentRect.width;
|
|
||||||
const h = ent.contentRect.height;
|
|
||||||
const dpr = devicePixelRatio||1;
|
|
||||||
this._c.width = w*dpr;
|
|
||||||
this._c.height = h*dpr;
|
|
||||||
this._ctx.scale(dpr,dpr);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this._ro.observe(this);
|
|
||||||
|
|
||||||
// track mouse so eyes follow
|
|
||||||
this._shadow.addEventListener('mousemove', e=>{
|
|
||||||
const r = this._c.getBoundingClientRect();
|
|
||||||
this._mouse.x = e.clientX - r.left;
|
|
||||||
this._mouse.y = e.clientY - r.top;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._lastTime = performance.now();
|
|
||||||
this._raf = requestAnimationFrame(t=>this._loop(t));
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback(){
|
|
||||||
cancelAnimationFrame(this._raf);
|
|
||||||
this._ro.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
_updateBlink(dt){
|
|
||||||
this._blinkTimer -= dt;
|
|
||||||
if (this._blinkTimer<=0){
|
|
||||||
this._blinking = !this._blinking;
|
|
||||||
this._blinkTimer = this._blinking
|
|
||||||
? 0.1
|
|
||||||
: 2 + Math.random()*3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_roundRect(x,y,w,h,r){
|
|
||||||
const ctx = this._ctx;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(x+r,y);
|
|
||||||
ctx.lineTo(x+w-r,y);
|
|
||||||
ctx.quadraticCurveTo(x+w,y, x+w,y+r);
|
|
||||||
ctx.lineTo(x+w,y+h-r);
|
|
||||||
ctx.quadraticCurveTo(x+w,y+h, x+w-r,y+h);
|
|
||||||
ctx.lineTo(x+r,y+h);
|
|
||||||
ctx.quadraticCurveTo(x,y+h, x,y+h-r);
|
|
||||||
ctx.lineTo(x,y+r);
|
|
||||||
ctx.quadraticCurveTo(x,y, x+r,y);
|
|
||||||
ctx.closePath();
|
|
||||||
}
|
|
||||||
|
|
||||||
_draw(ts){
|
|
||||||
const ctx = this._ctx;
|
|
||||||
const W = this._c.clientWidth;
|
|
||||||
const H = this._c.clientHeight;
|
|
||||||
ctx.clearRect(0,0,W,H);
|
|
||||||
|
|
||||||
// HEAD + BOB
|
|
||||||
const cx = W/2;
|
|
||||||
const cy = H/2 + Math.sin(ts*0.002)*8;
|
|
||||||
const R = Math.min(W,H)*0.25;
|
|
||||||
|
|
||||||
// SPEECH BALLOON
|
|
||||||
if (this._text){
|
|
||||||
const pad = 6;
|
|
||||||
ctx.font = `${R*0.15}px sans-serif`;
|
|
||||||
const m = ctx.measureText(this._text);
|
|
||||||
const tw = m.width, th = R*0.18;
|
|
||||||
const bw = tw + pad*2, bh = th + pad*2;
|
|
||||||
const bx = cx - bw/2, by = cy - R - bh - 10;
|
|
||||||
// bubble
|
|
||||||
ctx.fillStyle = this._balloonColor;
|
|
||||||
this._roundRect(bx,by,bw,bh,6);
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = '#888';
|
|
||||||
ctx.lineWidth = 1.2;
|
|
||||||
ctx.stroke();
|
|
||||||
// tail
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cx-6, by+bh);
|
|
||||||
ctx.lineTo(cx+6, by+bh);
|
|
||||||
ctx.lineTo(cx, cy-R+4);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fill();
|
|
||||||
ctx.stroke();
|
|
||||||
// text
|
|
||||||
ctx.fillStyle = this._textColor;
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillText(this._text, bx+pad, by+pad);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FACE
|
|
||||||
ctx.fillStyle = this._faceColor;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx,cy,R,0,2*Math.PI);
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
// EYES
|
|
||||||
const eyeY = cy - R*0.2;
|
|
||||||
const eyeX = R*0.4;
|
|
||||||
const eyeR= R*0.12;
|
|
||||||
const pupR= eyeR*0.5;
|
|
||||||
|
|
||||||
for(let i=0;i<2;i++){
|
|
||||||
const ex = cx + (i? eyeX:-eyeX);
|
|
||||||
const ey = eyeY;
|
|
||||||
// eyeball
|
|
||||||
ctx.fillStyle = '#fff';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(ex,ey,eyeR,0,2*Math.PI);
|
|
||||||
ctx.fill();
|
|
||||||
// pupil follows
|
|
||||||
let dx = this._mouse.x - ex;
|
|
||||||
let dy = this._mouse.y - ey;
|
|
||||||
const d = Math.hypot(dx,dy);
|
|
||||||
const max = eyeR - pupR - 2;
|
|
||||||
if (d>max){ dx=dx/d*max; dy=dy/d*max; }
|
|
||||||
if (this._blinking){
|
|
||||||
ctx.strokeStyle='#000';
|
|
||||||
ctx.lineWidth=3;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(ex-eyeR,ey);
|
|
||||||
ctx.lineTo(ex+eyeR,ey);
|
|
||||||
ctx.stroke();
|
|
||||||
} else {
|
|
||||||
ctx.fillStyle = this._eyeColor;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(ex+dx,ey+dy,pupR,0,2*Math.PI);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ANGRY BROWS
|
|
||||||
if (this._emotion==='angry'){
|
|
||||||
ctx.strokeStyle='#000';
|
|
||||||
ctx.lineWidth=4;
|
|
||||||
[[-eyeX,1],[ eyeX,-1]].forEach(([off,dir])=>{
|
|
||||||
const sx = cx+off - eyeR;
|
|
||||||
const sy = eyeY - eyeR*1.3;
|
|
||||||
const ex = cx+off + eyeR;
|
|
||||||
const ey2= sy + dir*6;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(sx,sy);
|
|
||||||
ctx.lineTo(ex,ey2);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// MOUTH by emotion
|
|
||||||
const mw = R*0.6;
|
|
||||||
const my = cy + R*0.25;
|
|
||||||
ctx.strokeStyle='#a33';
|
|
||||||
ctx.lineWidth=4;
|
|
||||||
|
|
||||||
if (this._emotion==='surprised'){
|
|
||||||
ctx.fillStyle='#a33';
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx,my,mw*0.3,0,2*Math.PI);
|
|
||||||
ctx.fill();
|
|
||||||
}
|
|
||||||
else if (this._emotion==='sad'){
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx,my,mw/2,1.15*Math.PI,1.85*Math.PI,true);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
else if (this._emotion==='angry'){
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(cx-mw/2,my+2);
|
|
||||||
ctx.lineTo(cx+mw/2,my-2);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
const s = this._emotion==='happy'? 0.15*Math.PI:0.2*Math.PI;
|
|
||||||
const e = this._emotion==='happy'? 0.85*Math.PI:0.8*Math.PI;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(cx,my,mw/2,s,e);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_loop(ts){
|
|
||||||
const dt = (ts - this._lastTime)/1000;
|
|
||||||
this._lastTime = ts;
|
|
||||||
this._updateBlink(dt);
|
|
||||||
this._draw(ts);
|
|
||||||
this._raf = requestAnimationFrame(t=>this._loop(t));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
customElements.define('avatar-face', AvatarFace);
|
|
||||||
|
|
||||||
|
|
||||||
class AvatarReplacer {
|
|
||||||
constructor(target, opts={}){
|
|
||||||
this.target = target;
|
|
||||||
// record original inline styles so we can restore
|
|
||||||
this._oldVis = target.style.visibility || '';
|
|
||||||
this._oldPos = target.style.position || '';
|
|
||||||
// hide the target
|
|
||||||
target.style.visibility = 'hidden';
|
|
||||||
// measure
|
|
||||||
const rect = target.getBoundingClientRect();
|
|
||||||
// create avatar
|
|
||||||
this.avatar = document.createElement('avatar-face');
|
|
||||||
// copy all supported opts into attributes
|
|
||||||
['emotion','faceColor','eyeColor','text','balloonColor','textColor']
|
|
||||||
.forEach(k => {
|
|
||||||
const attr = k.replace(/[A-Z]/g,m=>'-'+m.toLowerCase());
|
|
||||||
if (opts[k] != null) this.avatar.setAttribute(attr, opts[k]);
|
|
||||||
});
|
|
||||||
// position absolutely
|
|
||||||
const scrollX = window.pageXOffset;
|
|
||||||
const scrollY = window.pageYOffset;
|
|
||||||
Object.assign(this.avatar.style, {
|
|
||||||
position: 'absolute',
|
|
||||||
left: (rect.left + scrollX) + 'px',
|
|
||||||
top: (rect.top + scrollY) + 'px',
|
|
||||||
width: rect.width + 'px',
|
|
||||||
height: rect.height + 'px',
|
|
||||||
zIndex: 9999
|
|
||||||
});
|
|
||||||
document.body.appendChild(this.avatar);
|
|
||||||
}
|
|
||||||
|
|
||||||
detach(){
|
|
||||||
// remove avatar and restore target
|
|
||||||
if (this.avatar && this.avatar.parentNode) {
|
|
||||||
this.avatar.parentNode.removeChild(this.avatar);
|
|
||||||
this.avatar = null;
|
|
||||||
}
|
|
||||||
this.target.style.visibility = this._oldVis;
|
|
||||||
this.target.style.position = this._oldPos;
|
|
||||||
}
|
|
||||||
|
|
||||||
// static convenience method
|
|
||||||
static attach(target, opts){
|
|
||||||
return new AvatarReplacer(target, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
// DEMO wiring
|
|
||||||
const btnGo = document.getElementById('go');
|
|
||||||
const btnReset = document.getElementById('reset');
|
|
||||||
let repl1, repl2;
|
|
||||||
|
|
||||||
btnGo.addEventListener('click', ()=>{
|
|
||||||
// replace #one with a happy avatar saying "Hi!"
|
|
||||||
repl1 = AvatarReplacer.attach(
|
|
||||||
document.getElementById('one'),
|
|
||||||
{emotion:'happy', text:'Hi!', balloonColor:'#fffbdd', textColor:'#333'}
|
|
||||||
);
|
|
||||||
// replace #two with a surprised avatar
|
|
||||||
repl2 = AvatarReplacer.attach(
|
|
||||||
document.getElementById('two'),
|
|
||||||
{emotion:'surprised', faceColor:'#eeffcc', text:'Wow!', balloonColor:'#ddffdd'}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
btnReset.addEventListener('click', ()=>{
|
|
||||||
if (repl1) repl1.detach();
|
|
||||||
if (repl2) repl2.detach();
|
|
||||||
});
|
|
||||||
*/
|
|
||||||
|
|
||||||
/*
|
|
||||||
class StarField {
|
class StarField {
|
||||||
constructor(container = document.body, options = {}) {
|
constructor({ count = 200, container = document.body } = {}) {
|
||||||
this.container = container;
|
this.container = container;
|
||||||
|
this.starCount = count;
|
||||||
this.stars = [];
|
this.stars = [];
|
||||||
this.setOptions(options);
|
this.positionMap = {};
|
||||||
|
this.originalColor = getComputedStyle(document.documentElement).getPropertyValue("--star-color").trim();
|
||||||
|
this._createStars();
|
||||||
|
window.stars = this.positionMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
setOptions({
|
_getStarPosition(star) {
|
||||||
starCount = 200,
|
const left = parseFloat(star.style.left);
|
||||||
minSize = 1,
|
const top = parseFloat(star.style.top);
|
||||||
maxSize = 3,
|
if (top < 40 && left >= 40 && left <= 60) return "North";
|
||||||
speed = 5,
|
if (top > 60 && left >= 40 && left <= 60) return "South";
|
||||||
color = "white"
|
if (left < 40 && top >= 40 && top <= 60) return "West";
|
||||||
}) {
|
if (left > 60 && top >= 40 && top <= 60) return "East";
|
||||||
this.options = { starCount, minSize, maxSize, speed, color };
|
if (top >= 40 && top <= 60 && left >= 40 && left <= 60) return "Center";
|
||||||
|
return "Corner or Edge";
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
_createStars() {
|
||||||
this.stars.forEach(star => star.remove());
|
for (let i = 0; i < this.starCount; i++) {
|
||||||
this.stars = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
generate() {
|
|
||||||
this.clear();
|
|
||||||
const { starCount, minSize, maxSize, speed, color } = this.options;
|
|
||||||
|
|
||||||
for (let i = 0; i < starCount; i++) {
|
|
||||||
const star = document.createElement("div");
|
const star = document.createElement("div");
|
||||||
star.classList.add("star");
|
star.classList.add("star");
|
||||||
const size = Math.random() * (maxSize - minSize) + minSize;
|
this._randomizeStar(star);
|
||||||
|
this._placeStar(star);
|
||||||
Object.assign(star.style, {
|
|
||||||
left: `${Math.random() * 100}%`,
|
|
||||||
top: `${Math.random() * 100}%`,
|
|
||||||
width: `${size}px`,
|
|
||||||
height: `${size}px`,
|
|
||||||
backgroundColor: color,
|
|
||||||
position: "absolute",
|
|
||||||
borderRadius: "50%",
|
|
||||||
opacity: "0.8",
|
|
||||||
animation: `twinkle ${speed}s ease-in-out infinite`,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.container.appendChild(star);
|
this.container.appendChild(star);
|
||||||
this.stars.push(star);
|
this.stars.push(star);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_randomizeStar(star) {
|
||||||
|
star.style.left = `${Math.random() * 100}%`;
|
||||||
|
star.style.top = `${Math.random() * 100}%`;
|
||||||
|
star.style.width = `${Math.random() * 2 + 1}px`;
|
||||||
|
star.style.height = `${Math.random() * 2 + 1}px`;
|
||||||
|
star.style.animationDelay = `${Math.random() * 2}s`;
|
||||||
|
star.style.position = "absolute";
|
||||||
|
star.style.transition = "top 1s ease, left 1s ease, opacity 1s ease";
|
||||||
|
|
||||||
|
star.shuffle = () => this._randomizeStar(star);
|
||||||
|
star.position = this._getStarPosition(star);
|
||||||
|
}
|
||||||
|
|
||||||
|
_placeStar(star) {
|
||||||
|
const pos = star.position;
|
||||||
|
if (!this.positionMap[pos]) this.positionMap[pos] = [];
|
||||||
|
this.positionMap[pos].push(star);
|
||||||
|
}
|
||||||
|
|
||||||
|
shuffleAll(duration = 1000) {
|
||||||
|
this.stars.forEach(star => {
|
||||||
|
star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease`;
|
||||||
|
star.style.filter = "drop-shadow(0 0 2px white)";
|
||||||
|
const left = Math.random() * 100;
|
||||||
|
const top = Math.random() * 100;
|
||||||
|
star.style.left = `${left}%`;
|
||||||
|
star.style.top = `${top}%`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
star.style.filter = "";
|
||||||
|
star.position = this._getStarPosition(star);
|
||||||
|
}, duration);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
glowColor(tempColor, duration = 2500) {
|
||||||
|
const lighten = (hex, percent) => {
|
||||||
|
const num = parseInt(hex.replace("#", ""), 16);
|
||||||
|
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||||
|
const g = Math.min(255, ((num >> 8) & 0xff) + Math.round(255 * percent / 100));
|
||||||
|
const b = Math.min(255, (num & 0xff) + Math.round(255 * percent / 100));
|
||||||
|
return `#${(1 << 24 | r << 16 | g << 8 | b).toString(16).slice(1)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const glow = lighten(tempColor, 10);
|
||||||
|
document.documentElement.style.setProperty("--star-color", glow);
|
||||||
|
setTimeout(() => {
|
||||||
|
document.documentElement.style.setProperty("--star-color", this.originalColor);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotify(text, { duration = 3000, color = "white", fontSize = "1.2em" } = {}) {
|
||||||
|
// Create container if needed
|
||||||
|
if (!this._notifyContainer) {
|
||||||
|
this._notifyContainer = document.createElement("div");
|
||||||
|
this._notifyContainer.className = "star-notify-container";
|
||||||
|
document.body.appendChild(this._notifyContainer);
|
||||||
|
}
|
||||||
|
const messages = document.querySelectorAll('.star-notify');
|
||||||
|
|
||||||
|
const count = Array.from(messages).filter(el => el.textContent.trim() === text).length;
|
||||||
|
|
||||||
|
if (count) return;
|
||||||
|
const note = document.createElement("div");
|
||||||
|
note.className = "star-notify";
|
||||||
|
note.textContent = text;
|
||||||
|
note.style.color = color;
|
||||||
|
note.style.fontSize = fontSize;
|
||||||
|
|
||||||
|
this._notifyContainer.appendChild(note);
|
||||||
|
|
||||||
|
// Trigger animation
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.opacity = 1;
|
||||||
|
note.style.transform = "translateY(0)";
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Remove after duration
|
||||||
|
setTimeout(() => {
|
||||||
|
note.style.opacity = 0;
|
||||||
|
note.style.transform = "translateY(-10px)";
|
||||||
|
setTimeout(() => note.remove(), 500);
|
||||||
|
}, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
const starField = new StarField(document.body, {
|
|
||||||
starCount: 200,
|
renderWord(word, { font = "bold", resolution = 8, duration = 1500, rainbow = false } = {}) {
|
||||||
minSize: 1,
|
const canvas = document.createElement("canvas");
|
||||||
maxSize: 3,
|
const ctx = canvas.getContext("2d");
|
||||||
speed: 5,
|
canvas.width = this.container.clientWidth;
|
||||||
color: "white"
|
canvas.height = this.container.clientHeight / 3;
|
||||||
});
|
|
||||||
*/
|
const fontSize = Math.floor(canvas.height / 2.5);
|
||||||
|
ctx.font = `${fontSize}px sans-serif`;
|
||||||
|
ctx.fillStyle = "black";
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(word, canvas.width / 2, canvas.height / 2);
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||||
|
const targetPositions = [];
|
||||||
|
|
||||||
|
for (let y = 0; y < canvas.height; y += resolution) {
|
||||||
|
for (let x = 0; x < canvas.width; x += resolution) {
|
||||||
|
const i = (y * canvas.width + x) * 4;
|
||||||
|
if (imageData[i + 3] > 128) {
|
||||||
|
targetPositions.push([
|
||||||
|
(x / canvas.width) * 100,
|
||||||
|
(y / canvas.height) * 100,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPositions.sort(() => Math.random() - 0.5);
|
||||||
|
const used = targetPositions.slice(0, this.stars.length);
|
||||||
|
|
||||||
|
this.stars.forEach((star, i) => {
|
||||||
|
star.style.transition = `top ${duration}ms ease, left ${duration}ms ease, opacity ${duration}ms ease, background-color 1s ease`;
|
||||||
|
if (i < used.length) {
|
||||||
|
const [left, top] = used[i];
|
||||||
|
star.style.left = `${left}%`;
|
||||||
|
star.style.top = `${top}%`;
|
||||||
|
star.style.opacity = 1;
|
||||||
|
if (rainbow) {
|
||||||
|
const hue = (i / used.length) * 360;
|
||||||
|
star.style.backgroundColor = `hsl(${hue}, 100%, 70%)`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
star.style.opacity = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.stars.forEach(star => {
|
||||||
|
star.position = this._getStarPosition(star);
|
||||||
|
if (rainbow) star.style.backgroundColor = "";
|
||||||
|
});
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
explodeAndReturn(duration = 1000) {
|
||||||
|
const originalPositions = this.stars.map(star => ({
|
||||||
|
left: star.style.left,
|
||||||
|
top: star.style.top
|
||||||
|
}));
|
||||||
|
|
||||||
|
this.stars.forEach(star => {
|
||||||
|
const angle = Math.random() * 2 * Math.PI;
|
||||||
|
const radius = Math.random() * 200;
|
||||||
|
const x = 50 + Math.cos(angle) * radius;
|
||||||
|
const y = 50 + Math.sin(angle) * radius;
|
||||||
|
star.style.transition = `top ${duration / 2}ms ease-out, left ${duration / 2}ms ease-out`;
|
||||||
|
star.style.left = `${x}%`;
|
||||||
|
star.style.top = `${y}%`;
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.stars.forEach((star, i) => {
|
||||||
|
star.style.transition = `top ${duration}ms ease-in, left ${duration}ms ease-in`;
|
||||||
|
star.style.left = originalPositions[i].left;
|
||||||
|
star.style.top = originalPositions[i].top;
|
||||||
|
});
|
||||||
|
}, duration / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
startColorCycle() {
|
||||||
|
let hue = 0;
|
||||||
|
if (this._colorInterval) clearInterval(this._colorInterval);
|
||||||
|
this._colorInterval = setInterval(() => {
|
||||||
|
hue = (hue + 2) % 360;
|
||||||
|
this.stars.forEach((star, i) => {
|
||||||
|
star.style.backgroundColor = `hsl(${(hue + i * 3) % 360}, 100%, 75%)`;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
stopColorCycle() {
|
||||||
|
if (this._colorInterval) {
|
||||||
|
clearInterval(this._colorInterval);
|
||||||
|
this._colorInterval = null;
|
||||||
|
this.stars.forEach(star => star.style.backgroundColor = "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSpecialStar({ title, content, category = "Info", color = "gold", onClick }) {
|
||||||
|
const star = this.stars.find(s => !s._dataAttached);
|
||||||
|
if (!star) return;
|
||||||
|
|
||||||
|
star.classList.add("special");
|
||||||
|
star.style.backgroundColor = color;
|
||||||
|
star._dataAttached = true;
|
||||||
|
star._specialData = { title, content, category, color, onClick };
|
||||||
|
|
||||||
|
const tooltip = document.getElementById("star-tooltip");
|
||||||
|
const showTooltip = (e) => {
|
||||||
|
tooltip.innerText = `${title} (${category})`;
|
||||||
|
tooltip.style.display = "block";
|
||||||
|
tooltip.style.left = `${e.clientX + 10}px`;
|
||||||
|
tooltip.style.top = `${e.clientY + 10}px`;
|
||||||
|
};
|
||||||
|
const hideTooltip = () => tooltip.style.display = "none";
|
||||||
|
|
||||||
|
star.addEventListener("mouseenter", showTooltip);
|
||||||
|
star.addEventListener("mouseleave", hideTooltip);
|
||||||
|
star.addEventListener("mousemove", showTooltip);
|
||||||
|
|
||||||
|
const showPopup = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this._showPopup(star, star._specialData);
|
||||||
|
if (onClick) onClick(star._specialData);
|
||||||
|
};
|
||||||
|
star.addEventListener("click", showPopup);
|
||||||
|
star.addEventListener("touchend", showPopup);
|
||||||
|
|
||||||
|
return star;
|
||||||
|
}
|
||||||
|
|
||||||
|
_showPopup(star, data) {
|
||||||
|
const popup = document.getElementById("star-popup");
|
||||||
|
popup.innerHTML = `<strong>${data.title}</strong><br><small>${data.category}</small><div style="margin-top: 5px;">${data.content}</div>`;
|
||||||
|
popup.style.display = "block";
|
||||||
|
|
||||||
|
const rect = star.getBoundingClientRect();
|
||||||
|
popup.style.left = `${rect.left + window.scrollX + 10}px`;
|
||||||
|
popup.style.top = `${rect.top + window.scrollY + 10}px`;
|
||||||
|
|
||||||
|
const closeHandler = () => {
|
||||||
|
popup.style.display = "none";
|
||||||
|
document.removeEventListener("click", closeHandler);
|
||||||
|
};
|
||||||
|
setTimeout(() => document.addEventListener("click", closeHandler), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeSpecialStar(star) {
|
||||||
|
if (!star._specialData) return;
|
||||||
|
|
||||||
|
star.classList.remove("special");
|
||||||
|
delete star._specialData;
|
||||||
|
star._dataAttached = false;
|
||||||
|
|
||||||
|
const clone = star.cloneNode(true);
|
||||||
|
star.replaceWith(clone);
|
||||||
|
|
||||||
|
const index = this.stars.indexOf(star);
|
||||||
|
if (index >= 0) this.stars[index] = clone;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSpecialStars() {
|
||||||
|
return this.stars
|
||||||
|
.filter(star => star._specialData)
|
||||||
|
.map(star => ({
|
||||||
|
star,
|
||||||
|
data: star._specialData
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const starField = new StarField({starCount: 200});
|
||||||
|
app.starField = starField;
|
||||||
|
|
||||||
|
class DemoSequence {
|
||||||
|
constructor(steps = []) {
|
||||||
|
this.steps = steps;
|
||||||
|
this.overlay = document.createElement("div");
|
||||||
|
this.overlay.className = "demo-overlay";
|
||||||
|
document.body.appendChild(this.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this._runStep(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_runStep(index) {
|
||||||
|
if (index >= this.steps.length) {
|
||||||
|
this._clearOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { target, text, duration = 3000 } = this.steps[index];
|
||||||
|
this._clearHighlights();
|
||||||
|
this._showOverlay(text);
|
||||||
|
|
||||||
|
if (target) {
|
||||||
|
const el = typeof target === 'string' ? document.querySelector(target) : target;
|
||||||
|
if (el) {
|
||||||
|
el.classList.remove("demo-highlight");
|
||||||
|
void el.offsetWidth; // force reflow to reset animation
|
||||||
|
el.classList.add("demo-highlight");
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this._hideOverlay();
|
||||||
|
this._runStep(index + 1);
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
_showOverlay(text) {
|
||||||
|
this.overlay.innerText = text;
|
||||||
|
this.overlay.style.animation = "demoFadeIn 0.8s ease forwards";
|
||||||
|
}
|
||||||
|
|
||||||
|
_hideOverlay() {
|
||||||
|
this.overlay.style.opacity = 0;
|
||||||
|
this._clearHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearOverlay() {
|
||||||
|
this.overlay.remove();
|
||||||
|
this._clearHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
_clearHighlights() {
|
||||||
|
document.querySelectorAll(".demo-highlight").forEach(el => {
|
||||||
|
el.classList.remove("demo-highlight");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
const demo = new DemoSequence([
|
||||||
|
{
|
||||||
|
text: "💬 Welcome to the Snek Developer Community!",
|
||||||
|
duration: 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: ".channels-list",
|
||||||
|
text: "🔗 Channels help you organize conversations.",
|
||||||
|
duration: 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: ".user-icon",
|
||||||
|
text: "👥 Invite team members here.",
|
||||||
|
duration: 3000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: ".chat-input",
|
||||||
|
text: "⌨️ Type your message and hit Enter!",
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
]);*/
|
||||||
|
|
||||||
|
// Start when ready (e.g., after load or user action)
|
||||||
|
//demo.start();
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+227
-261
@@ -13,316 +13,282 @@
|
|||||||
{% 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" %}
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { app } from "/app.js";
|
import { app } from "/app.js";
|
||||||
import { Schedule } from "/schedule.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
|
// --- Cache selectors ---
|
||||||
},
|
const chatInputField = document.querySelector("chat-input");
|
||||||
"/help": () => {
|
const messagesContainer = document.querySelector(".chat-messages");
|
||||||
showHelp();
|
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);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const textBox = document.querySelector("chat-input").textarea
|
// --- Scroll: load extra messages, throttled ---
|
||||||
textBox.addEventListener("paste", async (e) => {
|
let isLoadingExtra = false;
|
||||||
try {
|
async function loadExtra() {
|
||||||
const clipboardItems = await navigator.clipboard.read();
|
const firstMessage = messagesContainer.querySelector(".message:first-child");
|
||||||
|
if (isLoadingExtra || !isScrolledPastHalf() || !firstMessage) return;
|
||||||
// DataTransfer needs to be used to modify the files list of the input
|
isLoadingExtra = true;
|
||||||
const dt = new DataTransfer();
|
const messages = await app.rpc.getMessages(channelUid, 0, firstMessage.dataset.created_at);
|
||||||
|
if (messages.length) {
|
||||||
for (const clipboardItem of clipboardItems) {
|
const frag = document.createDocumentFragment();
|
||||||
const fileTypes = clipboardItem.types.filter(type => !type.startsWith('text/'))
|
messages.forEach(msg => {
|
||||||
for (const fileType of fileTypes) {
|
const temp = document.createElement("div");
|
||||||
|
temp.innerHTML = msg.html;
|
||||||
const blob = await clipboardItem.getType(fileType);
|
frag.appendChild(temp.firstChild);
|
||||||
// Do something with the image blob.
|
|
||||||
dt.items.add(new File([blob], "image.png", { type: fileType }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dt.items.length > 0) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
firstMessage.parentNode.insertBefore(frag, firstMessage);
|
||||||
|
updateLayout(false);
|
||||||
|
|
||||||
const chatInput = document.querySelector(".chat-area")
|
|
||||||
chatInput.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')
|
|
||||||
input.files = dt.files;
|
|
||||||
|
|
||||||
await uploadButton.uploadFiles();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
chatInput.addEventListener("dragover", async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.dataTransfer.dropEffect = "link";
|
|
||||||
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
chatInputField.textarea.focus();
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function replyMessage(message) {
|
|
||||||
const field = chatInputField
|
|
||||||
field.value = "```markdown\n> " + (message || '') + "\n```\n";
|
|
||||||
field.focus();
|
|
||||||
}
|
}
|
||||||
|
isLoadingExtra = false;
|
||||||
|
}
|
||||||
|
messagesContainer.addEventListener("scroll", throttle(loadExtra, 200));
|
||||||
|
|
||||||
function updateTimes() {
|
// --- Only update visible times ---
|
||||||
document.querySelectorAll(".time").forEach((container) => {
|
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 messageDiv = container.closest('.message');
|
||||||
const userNick = messageDiv.dataset.user_nick;
|
|
||||||
const text = messageDiv.querySelector(".text").innerText;
|
const text = messageDiv.querySelector(".text").innerText;
|
||||||
const time = document.createElement("span");
|
const time = document.createElement("span");
|
||||||
time.innerText = app.timeDescription(container.dataset.created_at);
|
time.innerText = app.timeDescription(container.dataset.created_at);
|
||||||
|
|
||||||
container.replaceChildren(time);
|
container.replaceChildren(time);
|
||||||
const reply = document.createElement("a");
|
const reply = document.createElement("a");
|
||||||
reply.innerText = " reply";
|
reply.innerText = " reply";
|
||||||
reply.href = "#reply";
|
reply.href = "#reply";
|
||||||
container.appendChild(reply);
|
container.appendChild(reply);
|
||||||
reply.addEventListener('click', (e) => {
|
reply.addEventListener('click', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
replyMessage(text);
|
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();
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
setInterval(() => requestIdleCallback(updateTimes), 30000);
|
||||||
|
|
||||||
let lastMessage
|
// --- Paste & drag/drop uploads ---
|
||||||
|
const textBox = chatInputField.textarea;
|
||||||
function updateLayout(doScrollDown) {
|
textBox.addEventListener("paste", async (e) => {
|
||||||
const messagesContainer = document.querySelector(".chat-messages");
|
try {
|
||||||
updateTimes();
|
const clipboardItems = await navigator.clipboard.read();
|
||||||
let previousUser = null;
|
const dt = new DataTransfer();
|
||||||
document.querySelectorAll(".message").forEach((message) => {
|
for (const item of clipboardItems) {
|
||||||
if (previousUser !== message.dataset.user_uid) {
|
for (const type of item.types.filter(t => !t.startsWith('text/'))) {
|
||||||
message.classList.add("switch-user");
|
const blob = await item.getType(type);
|
||||||
previousUser = message.dataset.user_uid;
|
dt.items.add(new File([blob], "image.png", { type }));
|
||||||
} else {
|
|
||||||
message.classList.remove("switch-user");
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
lastMessage = messagesContainer.querySelector(".message:last-child");
|
|
||||||
if (doScrollDown) {
|
|
||||||
messagesContainer.scrollToBottom()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
if (dt.items.length > 0) {
|
||||||
|
const uploadButton = chatInputField.uploadButton;
|
||||||
setInterval(updateTimes, 30000);
|
const input = uploadButton.shadowRoot.querySelector('.file-input');
|
||||||
|
input.files = dt.files;
|
||||||
function isMentionToMe(message){
|
await uploadButton.uploadFiles();
|
||||||
const mentionText = '@{{ user.username.value }}';
|
|
||||||
return message.toLowerCase().includes(mentionText);
|
|
||||||
}
|
|
||||||
function extractMentions(message) {
|
|
||||||
return [...new Set(message.match(/@\w+/g) || [])];
|
|
||||||
}
|
|
||||||
function isMentionForSomeoneElse(message){
|
|
||||||
const mentions = extractMentions(message);
|
|
||||||
const mentionText = '@{{ user.username.value }}';
|
|
||||||
return mentions.length > 0 && mentions.indexOf(mentionText) == -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.addEventListener("channel-message", (data) => {
|
|
||||||
let display = 'block';
|
|
||||||
if(!data.text || !data.text.trim()){
|
|
||||||
display = "none";
|
|
||||||
}
|
}
|
||||||
if (data.channel_uid !== channelUid) {
|
} catch (error) {
|
||||||
if(!isMentionForSomeoneElse(data.message)){
|
console.error("Failed to read clipboard contents: ", error);
|
||||||
channelSidebar.notify(data);
|
}
|
||||||
app.playSound("messageOtherChannel");
|
});
|
||||||
}
|
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');
|
||||||
|
input.files = dt.files;
|
||||||
|
await uploadButton.uploadFiles();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chatArea.addEventListener("dragover", e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = "link";
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
// --- Focus input on load ---
|
||||||
|
chatInputField.textarea.focus();
|
||||||
|
|
||||||
|
// --- Reply helper ---
|
||||||
|
function replyMessage(message) {
|
||||||
|
chatInputField.value = "```markdown\n> " + (message || '') + "\n```\n";
|
||||||
|
chatInputField.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
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 = data.text && data.text.trim() ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (data.channel_uid !== channelUid) {
|
||||||
|
if (!isMentionForSomeoneElse(data.message)) {
|
||||||
|
channelSidebar.notify(data);
|
||||||
|
app.playSound("messageOtherChannel");
|
||||||
}
|
}
|
||||||
if (data.username !== "{{ user.username.value }}") {
|
return;
|
||||||
if(isMentionToMe(data.message)){
|
}
|
||||||
app.playSound("mention");
|
if (data.username !== username) {
|
||||||
}else if (!isMentionForSomeoneElse(data.message)){
|
if (isMentionToMe(data.message)) {
|
||||||
|
app.playSound("mention");
|
||||||
|
} else if (!isMentionForSomeoneElse(data.message)) {
|
||||||
|
if(data.is_final){
|
||||||
app.playSound("message");
|
app.playSound("message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
const lastElement = messagesContainer.querySelector(".message-list-bottom");
|
||||||
|
const doScrollDown = messagesContainer.isScrolledToBottom();
|
||||||
|
|
||||||
const messagesContainer = document.querySelector(".chat-messages");
|
const oldMessage = messagesContainer.querySelector(`.message[data-uid="${data.uid}"]`);
|
||||||
lastMessage = messagesContainer.querySelector(".message:last-child");
|
if (oldMessage) {
|
||||||
const doScrollDownBecauseLastMessageIsVisible = !lastMessage || isElementVisible(lastMessage);
|
oldMessage.remove();
|
||||||
|
}
|
||||||
|
|
||||||
const message = document.createElement("div");
|
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);
|
|
||||||
app.rpc.markAsRead(channelUid);
|
|
||||||
});
|
|
||||||
|
|
||||||
let escPressed = false;
|
|
||||||
let gPressCount = 0;
|
|
||||||
let keyTimeout;
|
|
||||||
document.addEventListener('keydown', function(event) {
|
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
message.innerHTML = data.html;
|
||||||
escPressed = true;
|
message.style.display = display;
|
||||||
|
messagesContainer.insertBefore(message.firstChild, lastElement);
|
||||||
|
updateLayout(doScrollDown);
|
||||||
|
setTimeout(() => updateLayout(doScrollDown), 1000);
|
||||||
|
app.rpc.markAsRead(channelUid);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Keyboard shortcuts ---
|
||||||
|
let escPressed = false, gPressCount = 0, keyTimeout;
|
||||||
|
document.addEventListener('keydown', function(event) {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
gPressCount = 0;
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
if (event.key === 'g' && !chatInputField.isActive()) {
|
||||||
|
gPressCount++;
|
||||||
|
clearTimeout(keyTimeout);
|
||||||
|
keyTimeout = setTimeout(() => { gPressCount = 0; }, 300);
|
||||||
|
if (gPressCount === 2) {
|
||||||
gPressCount = 0;
|
gPressCount = 0;
|
||||||
clearTimeout(keyTimeout);
|
messagesContainer.querySelector(".message:first-child")?.scrollIntoView({ block: "end", inline: "nearest" });
|
||||||
keyTimeout = setTimeout(() => {
|
loadExtra();
|
||||||
escPressed = false;
|
|
||||||
}, 300);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if(event.key == 'i' && !chatInputField.isActive()) {
|
||||||
|
event.preventDefault();
|
||||||
|
chatInputField.focus();
|
||||||
|
}
|
||||||
|
if(event.key == 'r' && !chatInputField.isActive()) {
|
||||||
|
event.preventDefault();
|
||||||
|
const replyLinks = document.querySelectorAll('.time a')
|
||||||
|
if(replyLinks.length){
|
||||||
|
const replyLink = replyLinks[replyLinks.length - 1]
|
||||||
|
replyLink.click()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event.shiftKey && event.key === 'G') {
|
||||||
|
if (!chatInputField.isActive()) {
|
||||||
|
event.preventDefault();
|
||||||
|
updateLayout(true);
|
||||||
|
setTimeout(() => chatInputField.focus(), 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (event.key === 'G' && escPressed) {
|
// --- Image click-to-zoom (delegated) ---
|
||||||
gPressCount++;
|
// --- Layout update ---
|
||||||
|
function updateLayout(doScrollDown) {
|
||||||
clearTimeout(keyTimeout);
|
updateTimes();
|
||||||
keyTimeout = setTimeout(() => {
|
let previousUser = null, previousDate = null;
|
||||||
gPressCount = 0;
|
messagesContainer.querySelectorAll(".message").forEach((message) => {
|
||||||
}, 300);
|
if (previousUser !== message.dataset.user_uid) {
|
||||||
if (gPressCount === 2) {
|
message.classList.add("switch-user");
|
||||||
gPressCount = 0;
|
previousUser = message.dataset.user_uid;
|
||||||
escPressed = false;
|
previousDate = new Date(message.dataset.created_at);
|
||||||
|
} else {
|
||||||
messagesContainer.querySelector(".message:last-child").scrollIntoView({ block: "end", inline: "nearest" });
|
message.classList.remove("switch-user");
|
||||||
setTimeout(() => {
|
if (!previousDate) {
|
||||||
|
previousDate = new Date(message.dataset.created_at);
|
||||||
chatInputField.focus();
|
} else {
|
||||||
},500)
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (event.shiftKey && event.key === 'G') {
|
|
||||||
if(chatInputField.isActive()){
|
|
||||||
|
|
||||||
updateLayout(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
chatInputField.focus();
|
|
||||||
},500)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
if (doScrollDown) messagesContainer.scrollToBottom?.();
|
||||||
|
}
|
||||||
|
|
||||||
messagesContainer.addEventListener('click', (e) => {
|
// --- Utility: check if element is visible ---
|
||||||
if(e.target.tagName != 'IMG')
|
|
||||||
return
|
|
||||||
const img = e.target
|
|
||||||
if(e.target.classList.contains('avatar')){
|
|
||||||
return
|
|
||||||
}
|
|
||||||
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);
|
// --- Utility: check if scrolled past half ---
|
||||||
urlObj.search = ''
|
function isScrolledPastHalf() {
|
||||||
fullImg.src = urlObj.toString();
|
let scrollTop = messagesContainer.scrollTop;
|
||||||
|
let scrollableHeight = messagesContainer.scrollHeight - messagesContainer.clientHeight;
|
||||||
|
return scrollTop < scrollableHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
fullImg.alt = img.alt;
|
// --- Initial layout update ---
|
||||||
fullImg.style.maxWidth = '90%';
|
updateLayout(true);
|
||||||
fullImg.style.maxHeight = '90%';
|
|
||||||
|
|
||||||
overlay.appendChild(fullImg);
|
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
|
||||||
|
|
||||||
overlay.addEventListener('click', () => {
|
|
||||||
document.body.removeChild(overlay);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
updateLayout(true);
|
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+21
-6
@@ -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())
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"]))
|
||||||
|
|
||||||
|
|||||||
+85
-10
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import traceback
|
import traceback
|
||||||
|
import asyncio
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
from snek.system.model import now
|
from snek.system.model import now
|
||||||
@@ -21,13 +21,30 @@ import logging
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class RPCView(BaseView):
|
class RPCView(BaseView):
|
||||||
|
|
||||||
class RPCApi:
|
class RPCApi:
|
||||||
def __init__(self, view, ws):
|
def __init__(self, view, ws):
|
||||||
self.view = view
|
self.view = view
|
||||||
self.app = self.view.app
|
self.app = self.view.app
|
||||||
self.services = self.app.services
|
self.services = self.app.services
|
||||||
self.ws = ws
|
self.ws = ws
|
||||||
|
self.user_session = {}
|
||||||
|
self._scheduled = []
|
||||||
|
|
||||||
|
async def _session_ensure(self):
|
||||||
|
uid = await self.view.session_get("uid")
|
||||||
|
if not uid in self.user_session:
|
||||||
|
self.user_session[uid] = {
|
||||||
|
"said_hello": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def session_get(self, key, default):
|
||||||
|
await self._session_ensure()
|
||||||
|
return self.user_session[self.user_uid].get(key, default)
|
||||||
|
|
||||||
|
async def session_set(self, key, value):
|
||||||
|
await self._session_ensure()
|
||||||
|
self.user_session[self.user_uid][key] = value
|
||||||
|
return True
|
||||||
|
|
||||||
async def db_insert(self, table_name, record):
|
async def db_insert(self, table_name, record):
|
||||||
self._require_login()
|
self._require_login()
|
||||||
@@ -176,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)
|
||||||
@@ -183,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
|
||||||
@@ -204,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"]
|
||||||
|
|
||||||
@@ -297,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()
|
||||||
|
|
||||||
@@ -323,6 +365,13 @@ class RPCView(BaseView):
|
|||||||
async for record in self.services.channel.get_users(channel_uid)
|
async for record in self.services.channel.get_users(channel_uid)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def _schedule(self, seconds, call):
|
||||||
|
self._scheduled.append(call)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
await self.services.socket.send_to_user(self.user_uid, call)
|
||||||
|
self._scheduled.remove(call)
|
||||||
|
|
||||||
|
|
||||||
async def ping(self, callId, *args):
|
async def ping(self, callId, *args):
|
||||||
if self.user_uid:
|
if self.user_uid:
|
||||||
user = await self.services.user.get(uid=self.user_uid)
|
user = await self.services.user.get(uid=self.user_uid)
|
||||||
@@ -330,7 +379,23 @@ class RPCView(BaseView):
|
|||||||
await self.services.user.save(user)
|
await self.services.user.save(user)
|
||||||
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 = []
|
||||||
|
async def schedule(uid, seconds, call):
|
||||||
|
scheduled.append(call)
|
||||||
|
await asyncio.sleep(seconds)
|
||||||
|
await self.services.socket.send_to_user(uid, call)
|
||||||
|
scheduled.remove(call)
|
||||||
|
|
||||||
ws = web.WebSocketResponse()
|
ws = web.WebSocketResponse()
|
||||||
await ws.prepare(self.request)
|
await ws.prepare(self.request)
|
||||||
if self.request.session.get("logged_in"):
|
if self.request.session.get("logged_in"):
|
||||||
@@ -343,6 +408,16 @@ class RPCView(BaseView):
|
|||||||
await self.services.socket.subscribe(
|
await self.services.socket.subscribe(
|
||||||
ws, subscription["channel_uid"], self.request.session.get("uid")
|
ws, subscription["channel_uid"], self.request.session.get("uid")
|
||||||
)
|
)
|
||||||
|
if not scheduled and self.request.app.uptime_seconds < 5:
|
||||||
|
await schedule(self.request.session.get("uid"),0,{"event":"refresh", "data": {
|
||||||
|
"message": "Finishing deployment"}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await schedule(self.request.session.get("uid"),15,{"event": "deployed", "data": {
|
||||||
|
"uptime": self.request.app.uptime}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
rpc = RPCView.RPCApi(self, ws)
|
rpc = RPCView.RPCApi(self, ws)
|
||||||
async for msg in ws:
|
async for msg in ws:
|
||||||
if msg.type == web.WSMsgType.TEXT:
|
if msg.type == web.WSMsgType.TEXT:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user