Compare commits
79
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 | ||
|
|
a0fb214332 | ||
|
|
f0545cbf02 | ||
|
|
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}")
|
||||||
|
|||||||
+221
-204
@@ -7,244 +7,261 @@
|
|||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
import { Schedule } from './schedule.js';
|
import { Schedule } from "./schedule.js";
|
||||||
import { EventHandler } from "./event-handler.js";
|
import { EventHandler } from "./event-handler.js";
|
||||||
import { Socket } from "./socket.js";
|
import { Socket } from "./socket.js";
|
||||||
|
|
||||||
export class RESTClient {
|
export class RESTClient {
|
||||||
debug = false;
|
debug = false;
|
||||||
|
|
||||||
async get(url, params = {}) {
|
async get(url, params = {}) {
|
||||||
const encodedParams = new URLSearchParams(params);
|
const encodedParams = new URLSearchParams(params);
|
||||||
if (encodedParams) url += '?' + encodedParams;
|
if (encodedParams) url += "?" + encodedParams;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug({ url, params, result });
|
console.debug({ url, params, result });
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
async post(url, data) {
|
async post(url, data) {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (this.debug) {
|
if (this.debug) {
|
||||||
console.debug({ url, data, result });
|
console.debug({ url, data, result });
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Chat extends EventHandler {
|
export class Chat extends EventHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this._url = window.location.hostname === 'localhost' ? 'ws://localhost/chat.ws' : 'wss://' + window.location.hostname + '/chat.ws';
|
this._url =
|
||||||
this._socket = null;
|
window.location.hostname === "localhost"
|
||||||
this._waitConnect = null;
|
? "ws://localhost/chat.ws"
|
||||||
this._promises = {};
|
: "wss://" + window.location.hostname + "/chat.ws";
|
||||||
|
this._socket = null;
|
||||||
|
this._waitConnect = null;
|
||||||
|
this._promises = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this._waitConnect) {
|
||||||
|
return this._waitConnect;
|
||||||
}
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._waitConnect = resolve;
|
||||||
|
console.debug("Connecting..");
|
||||||
|
|
||||||
connect() {
|
try {
|
||||||
if (this._waitConnect) {
|
this._socket = new WebSocket(this._url);
|
||||||
return this._waitConnect;
|
} catch (e) {
|
||||||
}
|
console.warn(e);
|
||||||
return new Promise((resolve) => {
|
setTimeout(() => {
|
||||||
this._waitConnect = resolve;
|
this.ensureConnection();
|
||||||
console.debug("Connecting..");
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
this._socket.onconnect = () => {
|
||||||
this._socket = new WebSocket(this._url);
|
this._connected();
|
||||||
} catch (e) {
|
this._waitSocket();
|
||||||
console.warn(e);
|
};
|
||||||
setTimeout(() => {
|
});
|
||||||
this.ensureConnection();
|
}
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
this._socket.onconnect = () => {
|
generateUniqueId() {
|
||||||
this._connected();
|
return "id-" + Math.random().toString(36).substr(2, 9);
|
||||||
this._waitSocket();
|
}
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
generateUniqueId() {
|
call(method, ...args) {
|
||||||
return 'id-' + Math.random().toString(36).substr(2, 9);
|
return new Promise((resolve, reject) => {
|
||||||
}
|
try {
|
||||||
|
const command = { method, args, message_id: this.generateUniqueId() };
|
||||||
|
this._promises[command.message_id] = resolve;
|
||||||
|
this._socket.send(JSON.stringify(command));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
call(method, ...args) {
|
_connected() {
|
||||||
return new Promise((resolve, reject) => {
|
this._socket.onmessage = (event) => {
|
||||||
try {
|
const message = JSON.parse(event.data);
|
||||||
const command = { method, args, message_id: this.generateUniqueId() };
|
if (message.message_id && this._promises[message.message_id]) {
|
||||||
this._promises[command.message_id] = resolve;
|
this._promises[message.message_id](message);
|
||||||
this._socket.send(JSON.stringify(command));
|
delete this._promises[message.message_id];
|
||||||
} catch (e) {
|
} else {
|
||||||
reject(e);
|
this.emit("message", message);
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
}
|
this._socket.onclose = () => {
|
||||||
|
this._waitSocket = null;
|
||||||
|
this._socket = null;
|
||||||
|
this.emit("close");
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
_connected() {
|
async privmsg(room, text) {
|
||||||
this._socket.onmessage = (event) => {
|
await rest.post("/api/privmsg", {
|
||||||
const message = JSON.parse(event.data);
|
room,
|
||||||
if (message.message_id && this._promises[message.message_id]) {
|
text,
|
||||||
this._promises[message.message_id](message);
|
});
|
||||||
delete this._promises[message.message_id];
|
}
|
||||||
} else {
|
|
||||||
this.emit("message", message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this._socket.onclose = () => {
|
|
||||||
this._waitSocket = null;
|
|
||||||
this._socket = null;
|
|
||||||
this.emit('close');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async privmsg(room, text) {
|
|
||||||
await rest.post("/api/privmsg", {
|
|
||||||
room,
|
|
||||||
text,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class NotificationAudio {
|
export class NotificationAudio {
|
||||||
constructor(timeout = 500) {
|
constructor(timeout = 500) {
|
||||||
this.schedule = new Schedule(timeout);
|
this.schedule = new Schedule(timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
sounds = {
|
sounds = {
|
||||||
"message": "/audio/soundfx.d_beep3.mp3",
|
message: "/audio/soundfx.d_beep3.mp3",
|
||||||
"mention": "/audio/750607__deadrobotmusic__notification-sound-1.wav",
|
mention: "/audio/750607__deadrobotmusic__notification-sound-1.wav",
|
||||||
"messageOtherChannel": "/audio/750608__deadrobotmusic__notification-sound-2.wav",
|
messageOtherChannel:
|
||||||
"ping": "/audio/750609__deadrobotmusic__notification-sound-3.wav",
|
"/audio/750608__deadrobotmusic__notification-sound-2.wav",
|
||||||
}
|
ping: "/audio/750609__deadrobotmusic__notification-sound-3.wav",
|
||||||
|
};
|
||||||
|
|
||||||
play(soundIndex = 0) {
|
play(soundIndex = 0) {
|
||||||
this.schedule.delay(() => {
|
this.schedule.delay(() => {
|
||||||
new Audio(this.sounds[soundIndex]).play()
|
new Audio(this.sounds[soundIndex])
|
||||||
.then(() => {
|
.play()
|
||||||
console.debug("Gave sound notification");
|
.then(() => {
|
||||||
})
|
console.debug("Gave sound notification");
|
||||||
.catch(error => {
|
})
|
||||||
console.error("Notification failed:", error);
|
.catch((error) => {
|
||||||
});
|
console.error("Notification failed:", error);
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class App extends EventHandler {
|
export class App extends EventHandler {
|
||||||
rest = new RESTClient();
|
rest = new RESTClient();
|
||||||
ws = null;
|
ws = null;
|
||||||
rpc = null;
|
rpc = null;
|
||||||
audio = null;
|
audio = null;
|
||||||
user = {};
|
user = {};
|
||||||
typeLock = null;
|
typeLock = null;
|
||||||
typeListener = null
|
typeListener = null;
|
||||||
typeEventChannelUid = null
|
typeEventChannelUid = null;
|
||||||
async set_typing(channel_uid){
|
_debug = false
|
||||||
this.typeEventChannel_uid = channel_uid
|
async set_typing(channel_uid) {
|
||||||
}
|
this.typeEventChannel_uid = channel_uid;
|
||||||
|
}
|
||||||
|
debug() {
|
||||||
|
this._debug = !this._debug;
|
||||||
|
this.ws._debug = this._debug;
|
||||||
|
}
|
||||||
async ping(...args) {
|
async ping(...args) {
|
||||||
if (this.is_pinging) return false
|
if (this.is_pinging) return false;
|
||||||
this.is_pinging = true
|
this.is_pinging = true;
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
this.is_pinging = false
|
this.is_pinging = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async forcePing(...arg) {
|
async forcePing(...arg) {
|
||||||
await this.rpc.ping(...args);
|
await this.rpc.ping(...args);
|
||||||
}
|
}
|
||||||
|
starField = null
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.ws = new Socket();
|
||||||
|
this.rpc = this.ws.client;
|
||||||
|
this.audio = new NotificationAudio(500);
|
||||||
|
this.is_pinging = false;
|
||||||
|
this.ping_interval = setInterval(() => {
|
||||||
|
this.ping("active");
|
||||||
|
}, 15000);
|
||||||
|
this.typeEventChannelUid = null;
|
||||||
|
this.typeListener = setInterval(() => {
|
||||||
|
if (this.typeEventChannelUid) {
|
||||||
|
this.rpc.set_typing(this.typeEventChannelUid);
|
||||||
|
this.typeEventChannelUid = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
constructor() {
|
const me = this;
|
||||||
super();
|
this.ws.addEventListener("connected", (data) => {
|
||||||
this.ws = new Socket();
|
this.ping("online");
|
||||||
this.rpc = this.ws.client;
|
});
|
||||||
this.audio = new NotificationAudio(500);
|
this.ws.addEventListener("reconnecting", (data) => {
|
||||||
this.is_pinging = false
|
this.starField?.showNotify("Connecting..","#CC0000")
|
||||||
this.ping_interval = setInterval(() => {
|
})
|
||||||
this.ping("active")
|
this.ws.addEventListener("channel-message", (data) => {
|
||||||
}, 15000)
|
me.emit("channel-message", data);
|
||||||
this.typeEventChannelUid = null
|
});
|
||||||
this.typeListener = setInterval(()=>{
|
this.ws.addEventListener("data", (data) => {
|
||||||
if(this.typeEventChannelUid){
|
if(this._debug){
|
||||||
this.rpc.set_typing(this.typeEventChannelUid)
|
console.debug(data)
|
||||||
this.typeEventChannelUid = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const me = this
|
|
||||||
this.ws.addEventListener("connected", (data) => {
|
|
||||||
this.ping("online")
|
|
||||||
})
|
|
||||||
|
|
||||||
this.ws.addEventListener("channel-message", (data) => {
|
|
||||||
me.emit("channel-message", data);
|
|
||||||
});
|
|
||||||
this.ws.addEventListener("event",(data)=>{
|
|
||||||
console.info("aaaa")
|
|
||||||
})
|
|
||||||
this.rpc.getUser(null).then(user => {
|
|
||||||
me.user = user;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
playSound(index) {
|
|
||||||
this.audio.play(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeDescription(isoDate) {
|
|
||||||
const date = new Date(isoDate);
|
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
|
||||||
return timeStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
timeAgo(date1, date2) {
|
|
||||||
const diffMs = Math.abs(date2 - date1);
|
|
||||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
if (days) {
|
|
||||||
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
|
|
||||||
}
|
}
|
||||||
if (hours) {
|
});
|
||||||
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
|
this.rpc.getUser(null).then((user) => {
|
||||||
}
|
me.user = user;
|
||||||
if (minutes) {
|
});
|
||||||
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
|
}
|
||||||
}
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
async benchMark(times = 100, message = "Benchmark Message") {
|
playSound(index) {
|
||||||
const promises = [];
|
this.audio.play(index);
|
||||||
const me = this;
|
}
|
||||||
for (let i = 0; i < times; i++) {
|
|
||||||
promises.push(this.rpc.getChannels().then(channels => {
|
timeDescription(isoDate) {
|
||||||
channels.forEach(channel => {
|
const date = new Date(isoDate);
|
||||||
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
});
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
}));
|
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
||||||
}
|
return timeStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
timeAgo(date1, date2) {
|
||||||
|
const diffMs = Math.abs(date2 - date1);
|
||||||
|
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor(
|
||||||
|
(diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60),
|
||||||
|
);
|
||||||
|
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
||||||
|
|
||||||
|
if (days) {
|
||||||
|
return `${days} ${days > 1 ? "days" : "day"} ago`;
|
||||||
}
|
}
|
||||||
|
if (hours) {
|
||||||
|
return `${hours} ${hours > 1 ? "hours" : "hour"} ago`;
|
||||||
|
}
|
||||||
|
if (minutes) {
|
||||||
|
return `${minutes} ${minutes > 1 ? "minutes" : "minute"} ago`;
|
||||||
|
}
|
||||||
|
return "just now";
|
||||||
|
}
|
||||||
|
|
||||||
|
async benchMark(times = 100, message = "Benchmark Message") {
|
||||||
|
const promises = [];
|
||||||
|
const me = this;
|
||||||
|
for (let i = 0; i < times; i++) {
|
||||||
|
promises.push(
|
||||||
|
this.rpc.getChannels().then((channels) => {
|
||||||
|
channels.forEach((channel) => {
|
||||||
|
me.rpc.sendMessage(channel.uid, `${message} ${i}`);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const app = new App();
|
export const app = new App();
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
+356
-218
@@ -1,235 +1,373 @@
|
|||||||
|
import { app } from "../app.js";
|
||||||
import { app } from '../app.js';
|
|
||||||
|
|
||||||
class ChatInputComponent extends HTMLElement {
|
class ChatInputComponent extends HTMLElement {
|
||||||
autoCompletions = {
|
autoCompletions = {
|
||||||
'example 1': () => {
|
"example 1": () => {},
|
||||||
|
"example 2": () => {},
|
||||||
|
}
|
||||||
|
hiddenCompletions = {
|
||||||
|
"/starsRender": () => {
|
||||||
|
app.rpc.starsRender(this.channelUid,this.value.replace("/starsRender ",""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
users = []
|
||||||
|
textarea = null
|
||||||
|
_value = ""
|
||||||
|
lastUpdateEvent = null
|
||||||
|
previousValue = ""
|
||||||
|
lastChange = null
|
||||||
|
changed = false
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.lastUpdateEvent = new Date();
|
||||||
|
this.textarea = document.createElement("textarea");
|
||||||
|
this.value = this.getAttribute("value") || "";
|
||||||
|
this.previousValue = this.value;
|
||||||
|
this.lastChange = new Date();
|
||||||
|
this.changed = false;
|
||||||
|
}
|
||||||
|
|
||||||
},
|
get value() {
|
||||||
'example 2': () => {
|
return this._value;
|
||||||
|
}
|
||||||
|
|
||||||
|
set value(value) {
|
||||||
|
this._value = value || "";
|
||||||
|
this.textarea.value = this._value;
|
||||||
|
}
|
||||||
|
get allAutoCompletions() {
|
||||||
|
return Object.assign({},this.autoCompletions,this.hiddenCompletions)
|
||||||
|
}
|
||||||
|
resolveAutoComplete() {
|
||||||
|
let count = 0;
|
||||||
|
let value = null;
|
||||||
|
|
||||||
|
Object.keys(this.allAutoCompletions).forEach((key) => {
|
||||||
|
if (key.startsWith(this.value.split(" ")[0])) {
|
||||||
|
count++;
|
||||||
|
value = key;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (count == 1) return value;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive() {
|
||||||
|
return document.activeElement === 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;
|
||||||
|
|
||||||
constructor() {
|
while ((match = regex.exec(text)) !== null) {
|
||||||
super();
|
mentions.push(match[1]);
|
||||||
this.lastUpdateEvent = new Date();
|
}
|
||||||
this.textarea = document.createElement("textarea");
|
|
||||||
this._value = "";
|
|
||||||
this.value = this.getAttribute("value") || "";
|
|
||||||
this.previousValue = this.value;
|
|
||||||
this.lastChange = new Date();
|
|
||||||
this.changed = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get value() {
|
return mentions;
|
||||||
return this._value;
|
}
|
||||||
}
|
matchMentionsToAuthors(mentions, authors) {
|
||||||
|
return mentions.map(mention => {
|
||||||
|
let closestAuthor = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
const lowerMention = mention.toLowerCase();
|
||||||
|
|
||||||
set value(value) {
|
authors.forEach(author => {
|
||||||
this._value = value || "";
|
const lowerAuthor = author.toLowerCase();
|
||||||
this.textarea.value = this._value;
|
let distance = this.levenshteinDistance(lowerMention, lowerAuthor);
|
||||||
}
|
|
||||||
|
|
||||||
resolveAutoComplete() {
|
|
||||||
let count = 0;
|
|
||||||
let value = null;
|
|
||||||
Object.keys(this.autoCompletions).forEach((key) => {
|
|
||||||
if (key.startsWith(this.value)) {
|
|
||||||
count++;
|
|
||||||
value = key;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (count == 1)
|
|
||||||
return value;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
isActive() {
|
if(!this.isSubsequence(lowerMention,lowerAuthor)) {
|
||||||
return document.activeElement === this.textarea;
|
distance += 10
|
||||||
}
|
|
||||||
|
|
||||||
focus() {
|
|
||||||
this.textarea.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
async connectedCallback() {
|
|
||||||
this.user = await app.rpc.getUser(null);
|
|
||||||
this.liveType = this.getAttribute("live-type") === "true";
|
|
||||||
this.liveTypeInterval = parseInt(this.getAttribute("live-type-interval")) || 3;
|
|
||||||
this.channelUid = this.getAttribute("channel");
|
|
||||||
this.messageUid = null;
|
|
||||||
|
|
||||||
this.classList.add("chat-input");
|
|
||||||
|
|
||||||
this.textarea.setAttribute("placeholder", "Type a message...");
|
|
||||||
this.textarea.setAttribute("rows", "2");
|
|
||||||
|
|
||||||
this.appendChild(this.textarea);
|
|
||||||
|
|
||||||
this.uploadButton = document.createElement("upload-button");
|
|
||||||
this.uploadButton.setAttribute("channel", this.channelUid);
|
|
||||||
this.uploadButton.addEventListener("upload", (e) => {
|
|
||||||
this.dispatchEvent(new CustomEvent("upload", e));
|
|
||||||
});
|
|
||||||
this.uploadButton.addEventListener("uploaded", (e) => {
|
|
||||||
this.dispatchEvent(new CustomEvent("uploaded", e));
|
|
||||||
});
|
|
||||||
|
|
||||||
this.appendChild(this.uploadButton);
|
|
||||||
|
|
||||||
this.textarea.addEventListener("keyup", (e) => {
|
|
||||||
if(e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
this.value = ''
|
|
||||||
e.target.value = '';
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.value = e.target.value;
|
|
||||||
this.changed = true;
|
|
||||||
this.update();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.textarea.addEventListener("keydown", (e) => {
|
|
||||||
this.value = e.target.value;
|
|
||||||
if (e.key === "Tab") {
|
|
||||||
e.preventDefault();
|
|
||||||
let autoCompletion = this.resolveAutoComplete();
|
|
||||||
if (autoCompletion) {
|
|
||||||
e.target.value = autoCompletion;
|
|
||||||
this.value = autoCompletion;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const message = e.target.value;
|
|
||||||
this.messageUid = null;
|
|
||||||
this.value = '';
|
|
||||||
this.previousValue = '';
|
|
||||||
|
|
||||||
if (!message) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let autoCompletion = this.autoCompletions[message];
|
|
||||||
if (autoCompletion) {
|
|
||||||
this.value = '';
|
|
||||||
this.previousValue = '';
|
|
||||||
e.target.value = '';
|
|
||||||
autoCompletion();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
e.target.value = '';
|
|
||||||
this.value = '';
|
|
||||||
this.messageUid = null;
|
|
||||||
this.sendMessage(this.channelUid, message).then((uid) => {
|
|
||||||
this.messageUid = uid;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.changeInterval = setInterval(() => {
|
|
||||||
if (!this.liveType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.value !== this.previousValue) {
|
|
||||||
if (this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval) {
|
|
||||||
this.value = '';
|
|
||||||
this.previousValue = '';
|
|
||||||
}
|
|
||||||
this.lastChange = new Date();
|
|
||||||
}
|
|
||||||
this.update();
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
this.addEventListener("upload", (e) => {
|
|
||||||
this.focus();
|
|
||||||
});
|
|
||||||
this.addEventListener("uploaded", function (e) {
|
|
||||||
let message = "";
|
|
||||||
e.detail.files.forEach((file) => {
|
|
||||||
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
|
||||||
});
|
|
||||||
app.rpc.sendMessage(this.channelUid, message);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
trackSecondsBetweenEvents(event1Time, event2Time) {
|
|
||||||
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
|
||||||
return millisecondsDifference / 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
newMessage() {
|
|
||||||
if (!this.messageUid) {
|
|
||||||
this.messageUid = '?';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sendMessage(this.channelUid, this.value).then((uid) => {
|
if (distance < minDistance) {
|
||||||
this.messageUid = uid;
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
updateMessage() {
|
return matrix[b.length][a.length];
|
||||||
if (this.value[0] == "/") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!this.messageUid) {
|
|
||||||
this.newMessage();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (this.messageUid === '?') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (typeof app !== "undefined" && app.rpc && typeof app.rpc.updateMessageText === "function") {
|
|
||||||
app.rpc.updateMessageText(this.messageUid, this.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus() {
|
|
||||||
if (this.liveType) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
|
|
||||||
this.lastUpdateEvent = new Date();
|
|
||||||
if (typeof app !== "undefined" && app.rpc && typeof app.rpc.set_typing === "function") {
|
|
||||||
app.rpc.set_typing(this.channelUid,this.user.color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
update() {
|
|
||||||
const expired = this.trackSecondsBetweenEvents(this.lastChange, new Date()) >= this.liveTypeInterval;
|
|
||||||
const changed = (this.value !== this.previousValue);
|
|
||||||
|
|
||||||
if (changed || expired) {
|
|
||||||
this.lastChange = new Date();
|
|
||||||
this.updateStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.previousValue = this.value;
|
|
||||||
|
|
||||||
if (this.liveType && expired) {
|
|
||||||
this.value = "";
|
|
||||||
this.previousValue = "";
|
|
||||||
this.messageUid = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changed) {
|
|
||||||
if (this.liveType) {
|
|
||||||
this.updateMessage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(channelUid, value) {
|
|
||||||
if (!value.trim()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return await app.rpc.sendMessage(channelUid, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('chat-input', ChatInputComponent);
|
|
||||||
|
|
||||||
|
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() {
|
||||||
|
this.user = null
|
||||||
|
app.rpc.getUser(null).then((user) => {
|
||||||
|
this.user=user
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const me = this;
|
||||||
|
this.liveType = this.getAttribute("live-type") === "true";
|
||||||
|
this.liveTypeInterval =
|
||||||
|
parseInt(this.getAttribute("live-type-interval")) || 6;
|
||||||
|
this.channelUid = this.getAttribute("channel");
|
||||||
|
|
||||||
|
app.rpc.getRecentUsers(this.channelUid).then(users=>{
|
||||||
|
this.users = users
|
||||||
|
})
|
||||||
|
this.messageUid = null;
|
||||||
|
|
||||||
|
this.classList.add("chat-input");
|
||||||
|
|
||||||
|
this.textarea.setAttribute("placeholder", "Type a message...");
|
||||||
|
this.textarea.setAttribute("rows", "2");
|
||||||
|
|
||||||
|
this.appendChild(this.textarea);
|
||||||
|
|
||||||
|
this.uploadButton = document.createElement("upload-button");
|
||||||
|
this.uploadButton.setAttribute("channel", this.channelUid);
|
||||||
|
this.uploadButton.addEventListener("upload", (e) => {
|
||||||
|
this.dispatchEvent(new CustomEvent("upload", e));
|
||||||
|
});
|
||||||
|
this.uploadButton.addEventListener("uploaded", (e) => {
|
||||||
|
this.dispatchEvent(new CustomEvent("uploaded", e));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.appendChild(this.uploadButton);
|
||||||
|
|
||||||
|
this.textarea.addEventListener("keyup", (e) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
this.value = "";
|
||||||
|
e.target.value = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.value = e.target.value;
|
||||||
|
this.changed = true;
|
||||||
|
this.update();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.textarea.addEventListener("keydown", (e) => {
|
||||||
|
this.value = e.target.value;
|
||||||
|
let autoCompletion = null;
|
||||||
|
if (e.key === "Tab") {
|
||||||
|
e.preventDefault();
|
||||||
|
autoCompletion = this.resolveAutoComplete();
|
||||||
|
if (autoCompletion) {
|
||||||
|
e.target.value = autoCompletion;
|
||||||
|
this.value = autoCompletion;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const message = me.replaceMentionsWithAuthors(this.value);
|
||||||
|
e.target.value = "";
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let autoCompletionHandler = this.allAutoCompletions[this.value.split(" ")[0]];
|
||||||
|
if (autoCompletionHandler) {
|
||||||
|
autoCompletionHandler();
|
||||||
|
this.value = "";
|
||||||
|
this.previousValue = "";
|
||||||
|
e.target.value = "";
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateMessage()
|
||||||
|
app.rpc.finalizeMessage(this.messageUid)
|
||||||
|
this.value = "";
|
||||||
|
this.previousValue = "";
|
||||||
|
this.messageUid = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.changeInterval = setInterval(() => {
|
||||||
|
if (!this.liveType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.value !== this.previousValue) {
|
||||||
|
if (
|
||||||
|
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
|
||||||
|
this.liveTypeInterval
|
||||||
|
) {
|
||||||
|
this.value = "";
|
||||||
|
this.previousValue = "";
|
||||||
|
}
|
||||||
|
this.lastChange = new Date();
|
||||||
|
}
|
||||||
|
this.update();
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
this.addEventListener("upload", (e) => {
|
||||||
|
this.focus();
|
||||||
|
});
|
||||||
|
this.addEventListener("uploaded", function (e) {
|
||||||
|
let message = "";
|
||||||
|
e.detail.files.forEach((file) => {
|
||||||
|
message += `[${file.name}](/channel/attachment/${file.relative_url})`;
|
||||||
|
});
|
||||||
|
app.rpc.sendMessage(this.channelUid, message,true);
|
||||||
|
});
|
||||||
|
setTimeout(()=>{
|
||||||
|
this.focus();
|
||||||
|
},1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
trackSecondsBetweenEvents(event1Time, event2Time) {
|
||||||
|
const millisecondsDifference = event2Time.getTime() - event1Time.getTime();
|
||||||
|
return millisecondsDifference / 1000;
|
||||||
|
}
|
||||||
|
isSubsequence(s, t) {
|
||||||
|
let i = 0, j = 0;
|
||||||
|
while (i < s.length && j < t.length) {
|
||||||
|
if (s[i] === t[j]) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
}
|
||||||
|
return i === s.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
newMessage() {
|
||||||
|
if (!this.messageUid) {
|
||||||
|
this.messageUid = "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
this.value = this.replaceMentionsWithAuthors(this.value);
|
||||||
|
this.sendMessage(this.channelUid, this.value,!this.liveType).then((uid) => {
|
||||||
|
if (this.liveType) {
|
||||||
|
this.messageUid = uid;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMessage() {
|
||||||
|
if (this.value[0] == "/") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!this.messageUid) {
|
||||||
|
this.newMessage();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (this.messageUid === "?") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof app !== "undefined" &&
|
||||||
|
app.rpc &&
|
||||||
|
typeof app.rpc.updateMessageText === "function"
|
||||||
|
) {
|
||||||
|
app.rpc.updateMessageText(this.messageUid, this.replaceMentionsWithAuthors(this.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus() {
|
||||||
|
if (this.liveType) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.trackSecondsBetweenEvents(this.lastUpdateEvent, new Date()) > 1) {
|
||||||
|
this.lastUpdateEvent = new Date();
|
||||||
|
if (
|
||||||
|
typeof app !== "undefined" &&
|
||||||
|
app.rpc &&
|
||||||
|
typeof app.rpc.set_typing === "function"
|
||||||
|
) {
|
||||||
|
app.rpc.set_typing(this.channelUid, this.user.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update() {
|
||||||
|
const expired =
|
||||||
|
this.trackSecondsBetweenEvents(this.lastChange, new Date()) >=
|
||||||
|
this.liveTypeInterval;
|
||||||
|
const changed = this.value !== this.previousValue;
|
||||||
|
|
||||||
|
if (changed || expired) {
|
||||||
|
this.lastChange = new Date();
|
||||||
|
this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.previousValue = this.value;
|
||||||
|
|
||||||
|
if (this.liveType && expired) {
|
||||||
|
this.value = "";
|
||||||
|
this.previousValue = "";
|
||||||
|
this.messageUid = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
if (this.liveType) {
|
||||||
|
this.updateMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(channelUid, value,is_final) {
|
||||||
|
if (!value.trim()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return await app.rpc.sendMessage(channelUid, value,is_final);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("chat-input", ChatInputComponent);
|
||||||
|
|||||||
@@ -12,71 +12,71 @@
|
|||||||
// 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.
|
// 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.
|
||||||
|
|
||||||
class ChatWindowElement extends HTMLElement {
|
class ChatWindowElement extends HTMLElement {
|
||||||
receivedHistory = false;
|
receivedHistory = false;
|
||||||
channel = null
|
channel = null;
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement('section');
|
this.component = document.createElement("section");
|
||||||
this.app = app;
|
this.app = app;
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
get user() {
|
get user() {
|
||||||
return this.app.user;
|
return this.app.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
const link = document.createElement('link');
|
const link = document.createElement("link");
|
||||||
link.rel = 'stylesheet';
|
link.rel = "stylesheet";
|
||||||
link.href = '/base.css';
|
link.href = "/base.css";
|
||||||
this.component.appendChild(link);
|
this.component.appendChild(link);
|
||||||
this.component.classList.add("chat-area");
|
this.component.classList.add("chat-area");
|
||||||
|
|
||||||
this.container = document.createElement("section");
|
this.container = document.createElement("section");
|
||||||
this.container.classList.add("chat-area", "chat-window");
|
this.container.classList.add("chat-area", "chat-window");
|
||||||
|
|
||||||
const chatHeader = document.createElement("div");
|
const chatHeader = document.createElement("div");
|
||||||
chatHeader.classList.add("chat-header");
|
chatHeader.classList.add("chat-header");
|
||||||
|
|
||||||
const chatTitle = document.createElement('h2');
|
const chatTitle = document.createElement("h2");
|
||||||
chatTitle.classList.add("chat-title");
|
chatTitle.classList.add("chat-title");
|
||||||
chatTitle.classList.add("no-select");
|
chatTitle.classList.add("no-select");
|
||||||
chatTitle.innerText = "Loading...";
|
chatTitle.innerText = "Loading...";
|
||||||
chatHeader.appendChild(chatTitle);
|
chatHeader.appendChild(chatTitle);
|
||||||
this.container.appendChild(chatHeader);
|
this.container.appendChild(chatHeader);
|
||||||
|
|
||||||
const channels = await app.rpc.getChannels();
|
const channels = await app.rpc.getChannels();
|
||||||
const channel = channels[0];
|
const channel = channels[0];
|
||||||
this.channel = channel;
|
this.channel = channel;
|
||||||
chatTitle.innerText = channel.name;
|
chatTitle.innerText = channel.name;
|
||||||
|
|
||||||
const channelElement = document.createElement('message-list');
|
const channelElement = document.createElement("message-list");
|
||||||
channelElement.setAttribute("channel", channel.uid);
|
channelElement.setAttribute("channel", channel.uid);
|
||||||
this.container.appendChild(channelElement);
|
this.container.appendChild(channelElement);
|
||||||
|
|
||||||
const chatInput = document.createElement('chat-input');
|
const chatInput = document.createElement("chat-input");
|
||||||
chatInput.chatWindow = this;
|
chatInput.chatWindow = this;
|
||||||
chatInput.addEventListener("submit", (e) => {
|
chatInput.addEventListener("submit", (e) => {
|
||||||
app.rpc.sendMessage(channel.uid, e.detail);
|
app.rpc.sendMessage(channel.uid, e.detail);
|
||||||
});
|
});
|
||||||
this.container.appendChild(chatInput);
|
this.container.appendChild(chatInput);
|
||||||
|
|
||||||
this.component.appendChild(this.container);
|
this.component.appendChild(this.container);
|
||||||
|
|
||||||
const messages = await app.rpc.getMessages(channel.uid);
|
const messages = await app.rpc.getMessages(channel.uid);
|
||||||
messages.forEach(message => {
|
messages.forEach((message) => {
|
||||||
if (!message['user_nick']) return;
|
if (!message["user_nick"]) return;
|
||||||
channelElement.addMessage(message);
|
channelElement.addMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
const me = this;
|
const me = this;
|
||||||
channelElement.addEventListener("message", (message) => {
|
channelElement.addEventListener("message", (message) => {
|
||||||
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
if (me.user.uid !== message.detail.user_uid) app.playSound(0);
|
||||||
|
|
||||||
message.detail.element.scrollIntoView({"block": "end"});
|
message.detail.element.scrollIntoView({ block: "end" });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('chat-window', ChatWindowElement);
|
customElements.define("chat-window", ChatWindowElement);
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
class DumbTerminal extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
--terminal-bg: #111;
|
||||||
|
--terminal-fg: #0f0;
|
||||||
|
--terminal-accent: #0ff;
|
||||||
|
--terminal-font: monospace;
|
||||||
|
|
||||||
|
display: block;
|
||||||
|
background: var(--terminal-bg);
|
||||||
|
color: var(--terminal-fg);
|
||||||
|
font-family: var(--terminal-font);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-line {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prompt {
|
||||||
|
color: var(--terminal-accent);
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--terminal-fg);
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="output" id="output"></div>
|
||||||
|
<div class="input-line">
|
||||||
|
<span class="prompt">></span>
|
||||||
|
<input type="text" id="input" autocomplete="off" autofocus />
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.outputEl = this.shadowRoot.getElementById("output");
|
||||||
|
this.inputEl = this.shadowRoot.getElementById("input");
|
||||||
|
|
||||||
|
this.history = [];
|
||||||
|
this.historyIndex = -1;
|
||||||
|
|
||||||
|
this.inputEl.addEventListener("keydown", (e) => this.onKeyDown(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown(event) {
|
||||||
|
const value = this.inputEl.value;
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case "Enter":
|
||||||
|
this.executeCommand(value);
|
||||||
|
this.history.push(value);
|
||||||
|
this.historyIndex = this.history.length;
|
||||||
|
this.inputEl.value = "";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowUp":
|
||||||
|
if (this.historyIndex > 0) {
|
||||||
|
this.historyIndex--;
|
||||||
|
this.inputEl.value = this.history[this.historyIndex];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ArrowDown":
|
||||||
|
if (this.historyIndex < this.history.length - 1) {
|
||||||
|
this.historyIndex++;
|
||||||
|
this.inputEl.value = this.history[this.historyIndex];
|
||||||
|
} else {
|
||||||
|
this.historyIndex = this.history.length;
|
||||||
|
this.inputEl.value = "";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeCommand(command) {
|
||||||
|
const outputLine = document.createElement("div");
|
||||||
|
outputLine.textContent = `> ${command}`;
|
||||||
|
this.outputEl.appendChild(outputLine);
|
||||||
|
|
||||||
|
const resultLine = document.createElement("div");
|
||||||
|
resultLine.textContent = this.mockExecute(command);
|
||||||
|
this.outputEl.appendChild(resultLine);
|
||||||
|
|
||||||
|
this.outputEl.scrollTop = this.outputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
mockExecute(command) {
|
||||||
|
switch (command.trim()) {
|
||||||
|
case "help":
|
||||||
|
return "Available commands: help, clear, date";
|
||||||
|
case "date":
|
||||||
|
return new Date().toString();
|
||||||
|
case "clear":
|
||||||
|
this.outputEl.innerHTML = "";
|
||||||
|
return "";
|
||||||
|
default:
|
||||||
|
return `Unknown command: ${command}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Static method to create a modal dialog with the terminal
|
||||||
|
* @returns {HTMLDialogElement}
|
||||||
|
*/
|
||||||
|
static createModal() {
|
||||||
|
const dialog = document.createElement("dialog");
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<div class="dialog-backdrop">
|
||||||
|
<web-terminal></web-terminal>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
dialog.showModal();
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("web-terminal", WebTerminal);
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
|
|
||||||
|
|
||||||
export class EventHandler {
|
export class EventHandler {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.subscribers = {};
|
this.subscribers = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(type, handler) {
|
addEventListener(type, handler) {
|
||||||
if (!this.subscribers[type]) this.subscribers[type] = [];
|
if (!this.subscribers[type]) this.subscribers[type] = [];
|
||||||
this.subscribers[type].push(handler);
|
this.subscribers[type].push(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
emit(type, ...data) {
|
emit(type, ...data) {
|
||||||
if (this.subscribers[type]) this.subscribers[type].forEach(handler => handler(...data));
|
if (this.subscribers[type])
|
||||||
}
|
this.subscribers[type].forEach((handler) => handler(...data));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,28 +2,25 @@
|
|||||||
|
|
||||||
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
// This JavaScript class defines a custom HTML element <fancy-button>, which creates a styled, clickable button element with customizable size, text, and URL redirect functionality.
|
||||||
|
|
||||||
|
|
||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class FancyButton extends HTMLElement {
|
class FancyButton extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.url = null;
|
this.url = null;
|
||||||
this.type = "button";
|
this.type = "button";
|
||||||
this.value = null;
|
this.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container = document.createElement('span');
|
this.container = document.createElement("span");
|
||||||
let size = this.getAttribute('size');
|
let size = this.getAttribute("size");
|
||||||
console.info({ GG: size });
|
console.info({ GG: size });
|
||||||
size = size === 'auto' ? '1%' : '33%';
|
size = size === "auto" ? "1%" : "33%";
|
||||||
|
|
||||||
this.styleElement = document.createElement("style");
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
:root {
|
:root {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
--width: 100%;
|
--width: 100%;
|
||||||
@@ -49,29 +46,30 @@ class FancyButton extends HTMLElement {
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
this.container.appendChild(this.styleElement);
|
this.container.appendChild(this.styleElement);
|
||||||
this.buttonElement = document.createElement('button');
|
this.buttonElement = document.createElement("button");
|
||||||
this.container.appendChild(this.buttonElement);
|
this.container.appendChild(this.buttonElement);
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
|
|
||||||
this.url = this.getAttribute('url');
|
this.url = this.getAttribute("url");
|
||||||
|
|
||||||
|
this.value = this.getAttribute("value");
|
||||||
|
this.buttonElement.appendChild(
|
||||||
|
document.createTextNode(this.getAttribute("text")),
|
||||||
|
);
|
||||||
|
this.buttonElement.addEventListener("click", () => {
|
||||||
|
if (this.url == "submit") {
|
||||||
|
this.closest("form").submit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.value = this.getAttribute('value');
|
if (this.url === "/back" || this.url === "/back/") {
|
||||||
this.buttonElement.appendChild(document.createTextNode(this.getAttribute("text")));
|
window.history.back();
|
||||||
this.buttonElement.addEventListener("click", () => {
|
} else if (this.url) {
|
||||||
if(this.url == 'submit'){
|
window.location = this.url;
|
||||||
this.closest('form').submit()
|
}
|
||||||
return
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.url === "/back" || this.url === "/back/") {
|
|
||||||
window.history.back();
|
|
||||||
} else if (this.url) {
|
|
||||||
window.location = this.url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("fancy-button", FancyButton);
|
customElements.define("fancy-button", FancyButton);
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ class FileBrowser extends HTMLElement {
|
|||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
this.path = ""; // current virtual path ("" = ROOT)
|
this.path = ""; // current virtual path ("" = ROOT)
|
||||||
this.offset = 0; // pagination offset
|
this.offset = 0; // pagination offset
|
||||||
this.limit = 40; // items per request
|
this.limit = 40; // items per request
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.path = this.getAttribute("path") || "";
|
this.path = this.getAttribute("path") || "";
|
||||||
this.renderShell();
|
this.renderShell();
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
@@ -40,19 +40,30 @@ class FileBrowser extends HTMLElement {
|
|||||||
<button id="next">Next</button>
|
<button id="next">Next</button>
|
||||||
</nav>
|
</nav>
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.getElementById("up").addEventListener("click", () => this.goUp());
|
this.shadowRoot
|
||||||
|
.getElementById("up")
|
||||||
|
.addEventListener("click", () => this.goUp());
|
||||||
this.shadowRoot.getElementById("prev").addEventListener("click", () => {
|
this.shadowRoot.getElementById("prev").addEventListener("click", () => {
|
||||||
if (this.offset > 0) { this.offset -= this.limit; this.load(); }
|
if (this.offset > 0) {
|
||||||
|
this.offset -= this.limit;
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
this.shadowRoot.getElementById("next").addEventListener("click", () => {
|
this.shadowRoot.getElementById("next").addEventListener("click", () => {
|
||||||
this.offset += this.limit; this.load();
|
this.offset += this.limit;
|
||||||
|
this.load();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Networking ----------------------------------------------
|
// ---------- Networking ----------------------------------------------
|
||||||
async load() {
|
async load() {
|
||||||
const r = await fetch(`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`);
|
const r = await fetch(
|
||||||
if (!r.ok) { console.error(await r.text()); return; }
|
`/drive.json?path=${encodeURIComponent(this.path)}&offset=${this.offset}&limit=${this.limit}`,
|
||||||
|
);
|
||||||
|
if (!r.ok) {
|
||||||
|
console.error(await r.text());
|
||||||
|
return;
|
||||||
|
}
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
this.renderTiles(data.items);
|
this.renderTiles(data.items);
|
||||||
this.updateNav(data.pagination);
|
this.updateNav(data.pagination);
|
||||||
@@ -62,13 +73,17 @@ class FileBrowser extends HTMLElement {
|
|||||||
renderTiles(items) {
|
renderTiles(items) {
|
||||||
const grid = this.shadowRoot.getElementById("grid");
|
const grid = this.shadowRoot.getElementById("grid");
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
items.forEach(item => {
|
items.forEach((item) => {
|
||||||
const tile = document.createElement("div");
|
const tile = document.createElement("div");
|
||||||
tile.className = "tile";
|
tile.className = "tile";
|
||||||
|
|
||||||
if (item.type === "directory") {
|
if (item.type === "directory") {
|
||||||
tile.innerHTML = `<div class="icon">📂</div><div>${item.name}</div>`;
|
tile.innerHTML = `<div class="icon">📂</div><div>${item.name}</div>`;
|
||||||
tile.addEventListener("click", () => { this.path = item.path; this.offset = 0; this.load(); });
|
tile.addEventListener("click", () => {
|
||||||
|
this.path = item.path;
|
||||||
|
this.offset = 0;
|
||||||
|
this.load();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
if (item.mimetype?.startsWith("image/")) {
|
if (item.mimetype?.startsWith("image/")) {
|
||||||
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
|
tile.innerHTML = `<img class="thumb" src="${item.url}" alt="${item.name}"><div>${item.name}</div>`;
|
||||||
@@ -87,7 +102,7 @@ class FileBrowser extends HTMLElement {
|
|||||||
this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
|
this.shadowRoot.getElementById("crumb").textContent = `/${this.path}`;
|
||||||
this.shadowRoot.getElementById("prev").disabled = offset === 0;
|
this.shadowRoot.getElementById("prev").disabled = offset === 0;
|
||||||
this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
|
this.shadowRoot.getElementById("next").disabled = offset + limit >= total;
|
||||||
this.shadowRoot.getElementById("up").disabled = this.path === "";
|
this.shadowRoot.getElementById("up").disabled = this.path === "";
|
||||||
}
|
}
|
||||||
|
|
||||||
goUp() {
|
goUp() {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class GenericField extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set value(val) {
|
set value(val) {
|
||||||
val = val ?? '';
|
val = val ?? "";
|
||||||
this.inputElement.value = val;
|
this.inputElement.value = val;
|
||||||
this.inputElement.setAttribute("value", val);
|
this.inputElement.setAttribute("value", val);
|
||||||
}
|
}
|
||||||
@@ -62,9 +62,9 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
|
|
||||||
h1 {
|
h1 {
|
||||||
@@ -174,7 +174,7 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
if (this.inputElement == null && this.field) {
|
if (this.inputElement == null && this.field) {
|
||||||
this.inputElement = document.createElement(this.field.tag);
|
this.inputElement = document.createElement(this.field.tag);
|
||||||
if (this.field.tag === 'button' && this.field.value === "submit") {
|
if (this.field.tag === "button" && this.field.value === "submit") {
|
||||||
this.action = this.field.value;
|
this.action = this.field.value;
|
||||||
}
|
}
|
||||||
this.inputElement.name = this.field.name;
|
this.inputElement.name = this.field.name;
|
||||||
@@ -182,26 +182,39 @@ class GenericField extends HTMLElement {
|
|||||||
|
|
||||||
const me = this;
|
const me = this;
|
||||||
this.inputElement.addEventListener("keyup", (e) => {
|
this.inputElement.addEventListener("keyup", (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === "Enter") {
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
|
|
||||||
me.dispatchEvent(new Event("submit"));
|
me.dispatchEvent(new Event("submit"));
|
||||||
} else if (me.field.value !== e.target.value) {
|
} else if (me.field.value !== e.target.value) {
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputElement.addEventListener("click", (e) => {
|
this.inputElement.addEventListener("click", (e) => {
|
||||||
const event = new CustomEvent("click", {detail: me, bubbles: true});
|
const event = new CustomEvent("click", { detail: me, bubbles: true });
|
||||||
me.dispatchEvent(event);
|
me.dispatchEvent(event);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.inputElement.addEventListener("blur", (e) => {
|
this.inputElement.addEventListener(
|
||||||
const event = new CustomEvent("change", {detail: me, bubbles: true});
|
"blur",
|
||||||
me.dispatchEvent(event);
|
(e) => {
|
||||||
}, true);
|
const event = new CustomEvent("change", {
|
||||||
|
detail: me,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
me.dispatchEvent(event);
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
this.container.appendChild(this.inputElement);
|
this.container.appendChild(this.inputElement);
|
||||||
}
|
}
|
||||||
@@ -210,8 +223,8 @@ class GenericField extends HTMLElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.inputElement.setAttribute("type", this.field.type ?? 'input');
|
this.inputElement.setAttribute("type", this.field.type ?? "input");
|
||||||
this.inputElement.setAttribute("name", this.field.name ?? '');
|
this.inputElement.setAttribute("name", this.field.name ?? "");
|
||||||
|
|
||||||
if (this.field.text != null) {
|
if (this.field.text != null) {
|
||||||
this.inputElement.innerText = this.field.text;
|
this.inputElement.innerText = this.field.text;
|
||||||
@@ -239,14 +252,14 @@ class GenericField extends HTMLElement {
|
|||||||
this.inputElement.removeAttribute("required");
|
this.inputElement.removeAttribute("required");
|
||||||
}
|
}
|
||||||
if (!this.footerElement) {
|
if (!this.footerElement) {
|
||||||
this.footerElement = document.createElement('div');
|
this.footerElement = document.createElement("div");
|
||||||
this.footerElement.style.clear = 'both';
|
this.footerElement.style.clear = "both";
|
||||||
this.container.appendChild(this.footerElement);
|
this.container.appendChild(this.footerElement);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('generic-field', GenericField);
|
customElements.define("generic-field", GenericField);
|
||||||
|
|
||||||
class GenericForm extends HTMLElement {
|
class GenericForm extends HTMLElement {
|
||||||
fields = {};
|
fields = {};
|
||||||
@@ -254,7 +267,7 @@ class GenericForm extends HTMLElement {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({mode: 'open'});
|
this.attachShadow({ mode: "open" });
|
||||||
this.styleElement = document.createElement("style");
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.innerHTML = `
|
this.styleElement.innerHTML = `
|
||||||
|
|
||||||
@@ -281,27 +294,29 @@ class GenericForm extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.appendChild(this.styleElement);
|
this.container.appendChild(this.styleElement);
|
||||||
this.container.classList.add("generic-form-container");
|
this.container.classList.add("generic-form-container");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
const preloadedForm = this.getAttribute('preloaded-structure');
|
const preloadedForm = this.getAttribute("preloaded-structure");
|
||||||
if (preloadedForm) {
|
if (preloadedForm) {
|
||||||
try {
|
try {
|
||||||
const form = JSON.parse(preloadedForm);
|
const form = JSON.parse(preloadedForm);
|
||||||
this.constructForm(form)
|
this.constructForm(form);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error, preloadedForm);
|
console.error(error, preloadedForm);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = this.getAttribute('url');
|
const url = this.getAttribute("url");
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
|
const fullUrl = url.startsWith("/")
|
||||||
|
? window.location.origin + url
|
||||||
|
: new URL(window.location.origin + "/http-get");
|
||||||
if (!url.startsWith("/")) {
|
if (!url.startsWith("/")) {
|
||||||
fullUrl.searchParams.set('url', url);
|
fullUrl.searchParams.set("url", url);
|
||||||
}
|
}
|
||||||
this.loadForm(fullUrl.toString());
|
this.loadForm(fullUrl.toString());
|
||||||
} else {
|
} else {
|
||||||
@@ -318,10 +333,10 @@ class GenericForm extends HTMLElement {
|
|||||||
let hasAutoFocus = Object.keys(this.fields).length !== 0;
|
let hasAutoFocus = Object.keys(this.fields).length !== 0;
|
||||||
|
|
||||||
fields.sort((a, b) => a.index - b.index);
|
fields.sort((a, b) => a.index - b.index);
|
||||||
fields.forEach(field => {
|
fields.forEach((field) => {
|
||||||
const updatingField = field.name in this.fields
|
const updatingField = field.name in this.fields;
|
||||||
|
|
||||||
this.fields[field.name] ??= document.createElement('generic-field');
|
this.fields[field.name] ??= document.createElement("generic-field");
|
||||||
|
|
||||||
const fieldElement = this.fields[field.name];
|
const fieldElement = this.fields[field.name];
|
||||||
|
|
||||||
@@ -362,7 +377,7 @@ class GenericForm extends HTMLElement {
|
|||||||
window.location.pathname = saveResult.redirect_url;
|
window.location.pathname = saveResult.redirect_url;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,7 +389,9 @@ class GenericForm extends HTMLElement {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`);
|
throw new Error(
|
||||||
|
`Failed to fetch: ${response.status} ${response.statusText}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.constructForm(await response.json());
|
await this.constructForm(await response.json());
|
||||||
@@ -387,15 +404,15 @@ class GenericForm extends HTMLElement {
|
|||||||
const url = this.getAttribute("url");
|
const url = this.getAttribute("url");
|
||||||
|
|
||||||
let response = await fetch(url, {
|
let response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({"action": "validate", "form": this.form})
|
body: JSON.stringify({ action: "validate", form: this.form }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = await response.json();
|
const form = await response.json();
|
||||||
Object.values(form.fields).forEach(field => {
|
Object.values(form.fields).forEach((field) => {
|
||||||
if (!this.form.fields[field.name]) {
|
if (!this.form.fields[field.name]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -409,23 +426,23 @@ class GenericForm extends HTMLElement {
|
|||||||
this.fields[field.name].setAttribute("field", field);
|
this.fields[field.name].setAttribute("field", field);
|
||||||
this.fields[field.name].updateAttributes();
|
this.fields[field.name].updateAttributes();
|
||||||
});
|
});
|
||||||
Object.values(form.fields).forEach(field => {
|
Object.values(form.fields).forEach((field) => {
|
||||||
this.fields[field.name].setErrors(field.errors);
|
this.fields[field.name].setErrors(field.errors);
|
||||||
});
|
});
|
||||||
return form['is_valid'];
|
return form["is_valid"];
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit() {
|
async submit() {
|
||||||
const url = this.getAttribute("url");
|
const url = this.getAttribute("url");
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({"action": "submit", "form": this.form})
|
body: JSON.stringify({ action: "submit", form: this.form }),
|
||||||
});
|
});
|
||||||
return await response.json();
|
return await response.json();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('generic-form', GenericForm);
|
customElements.define("generic-form", GenericForm);
|
||||||
|
|||||||
@@ -7,48 +7,50 @@
|
|||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
class HTMLFrame extends HTMLElement {
|
class HTMLFrame extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container.classList.add("html_frame");
|
this.container.classList.add("html_frame");
|
||||||
let url = this.getAttribute('url');
|
let url = this.getAttribute("url");
|
||||||
if (!url.startsWith("https")) {
|
if (!url.startsWith("https")) {
|
||||||
url = "https://" + url;
|
url = "https://" + url;
|
||||||
}
|
|
||||||
if (url) {
|
|
||||||
const fullUrl = url.startsWith("/") ? window.location.origin + url : new URL(window.location.origin + "/http-get");
|
|
||||||
if (!url.startsWith("/")) {
|
|
||||||
fullUrl.searchParams.set('url', url);
|
|
||||||
}
|
|
||||||
this.loadAndRender(fullUrl.toString());
|
|
||||||
} else {
|
|
||||||
this.container.textContent = "No source URL!";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (url) {
|
||||||
|
const fullUrl = url.startsWith("/")
|
||||||
|
? window.location.origin + url
|
||||||
|
: new URL(window.location.origin + "/http-get");
|
||||||
|
if (!url.startsWith("/")) {
|
||||||
|
fullUrl.searchParams.set("url", url);
|
||||||
|
}
|
||||||
|
this.loadAndRender(fullUrl.toString());
|
||||||
|
} else {
|
||||||
|
this.container.textContent = "No source URL!";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadAndRender(url) {
|
async loadAndRender(url) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
throw new Error(`Error: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
if (url.endsWith(".md")) {
|
if (url.endsWith(".md")) {
|
||||||
const markdownElement = document.createElement('div');
|
const markdownElement = document.createElement("div");
|
||||||
markdownElement.innerHTML = html;
|
markdownElement.innerHTML = html;
|
||||||
this.outerHTML = html;
|
this.outerHTML = html;
|
||||||
} else {
|
} else {
|
||||||
this.container.innerHTML = html;
|
this.container.innerHTML = html;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.container.textContent = `Error: ${error.message}`;
|
this.container.textContent = `Error: ${error.message}`;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('html-frame', HTMLFrame);
|
customElements.define("html-frame", HTMLFrame);
|
||||||
|
|||||||
@@ -12,22 +12,22 @@
|
|||||||
class HTMLFrame extends HTMLElement {
|
class HTMLFrame extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container.classList.add('html_frame');
|
this.container.classList.add("html_frame");
|
||||||
const url = this.getAttribute('url');
|
const url = this.getAttribute("url");
|
||||||
if (url) {
|
if (url) {
|
||||||
const fullUrl = url.startsWith('/')
|
const fullUrl = url.startsWith("/")
|
||||||
? window.location.origin + url
|
? window.location.origin + url
|
||||||
: new URL(window.location.origin + '/http-get');
|
: new URL(window.location.origin + "/http-get");
|
||||||
if (!url.startsWith('/')) fullUrl.searchParams.set('url', url);
|
if (!url.startsWith("/")) fullUrl.searchParams.set("url", url);
|
||||||
this.loadAndRender(fullUrl.toString());
|
this.loadAndRender(fullUrl.toString());
|
||||||
} else {
|
} else {
|
||||||
this.container.textContent = 'No source URL!';
|
this.container.textContent = "No source URL!";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,4 +45,4 @@ class HTMLFrame extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('markdown-frame', HTMLFrame);
|
customElements.define("markdown-frame", HTMLFrame);
|
||||||
|
|||||||
+98
-100
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
// No external libraries or dependencies are used other than standard web components.
|
// No external libraries or dependencies are used other than standard web components.
|
||||||
|
|
||||||
|
|
||||||
// MIT License
|
// 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:
|
// 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:
|
||||||
@@ -13,20 +12,19 @@
|
|||||||
//
|
//
|
||||||
// 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.
|
// 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.
|
||||||
|
|
||||||
|
|
||||||
class TileGridElement extends HTMLElement {
|
class TileGridElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.gridId = this.getAttribute('grid');
|
this.gridId = this.getAttribute("grid");
|
||||||
this.component = document.createElement('div');
|
this.component = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
console.log('connected');
|
console.log("connected");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.textContent = `
|
this.styleElement.textContent = `
|
||||||
.grid {
|
.grid {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -47,53 +45,53 @@ class TileGridElement extends HTMLElement {
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.component.appendChild(this.styleElement);
|
this.component.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.classList.add('gallery');
|
this.container.classList.add("gallery");
|
||||||
this.component.appendChild(this.container);
|
this.component.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
addImage(src) {
|
addImage(src) {
|
||||||
const item = document.createElement('img');
|
const item = document.createElement("img");
|
||||||
item.src = src;
|
item.src = src;
|
||||||
item.classList.add('tile');
|
item.classList.add("tile");
|
||||||
item.style.width = '100px';
|
item.style.width = "100px";
|
||||||
item.style.height = '100px';
|
item.style.height = "100px";
|
||||||
this.container.appendChild(item);
|
this.container.appendChild(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
addImages(srcs) {
|
addImages(srcs) {
|
||||||
srcs.forEach(src => this.addImage(src));
|
srcs.forEach((src) => this.addImage(src));
|
||||||
}
|
}
|
||||||
|
|
||||||
addElement(element) {
|
addElement(element) {
|
||||||
element.classList.add('tile');
|
element.classList.add("tile");
|
||||||
this.container.appendChild(element);
|
this.container.appendChild(element);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class UploadButton extends HTMLElement {
|
class UploadButton extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement('div');
|
this.component = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
window.u = this;
|
window.u = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
get gridSelector() {
|
get gridSelector() {
|
||||||
return this.getAttribute('grid');
|
return this.getAttribute("grid");
|
||||||
}
|
}
|
||||||
grid = null;
|
grid = null;
|
||||||
|
|
||||||
addImages(urls) {
|
addImages(urls) {
|
||||||
this.grid.addImages(urls);
|
this.grid.addImages(urls);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
console.log('connected');
|
console.log("connected");
|
||||||
this.styleElement = document.createElement('style');
|
this.styleElement = document.createElement("style");
|
||||||
this.styleElement.textContent = `
|
this.styleElement.textContent = `
|
||||||
.upload-button {
|
.upload-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -115,61 +113,61 @@ class UploadButton extends HTMLElement {
|
|||||||
background-color: #999;
|
background-color: #999;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.styleElement);
|
this.shadowRoot.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.classList.add('upload-button');
|
this.container.classList.add("upload-button");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
const input = document.createElement('input');
|
const input = document.createElement("input");
|
||||||
input.type = 'file';
|
input.type = "file";
|
||||||
input.accept = 'image/*';
|
input.accept = "image/*";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.addEventListener('change', (e) => {
|
input.addEventListener("change", (e) => {
|
||||||
const files = e.target.files;
|
const files = e.target.files;
|
||||||
const urls = [];
|
const urls = [];
|
||||||
for (let i = 0; i < files.length; i++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
urls.push(e.target.result);
|
urls.push(e.target.result);
|
||||||
if (urls.length === files.length) {
|
if (urls.length === files.length) {
|
||||||
this.addImages(urls);
|
this.addImages(urls);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(files[i]);
|
reader.readAsDataURL(files[i]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const label = document.createElement('label');
|
const label = document.createElement("label");
|
||||||
label.textContent = 'Upload Images';
|
label.textContent = "Upload Images";
|
||||||
label.appendChild(input);
|
label.appendChild(input);
|
||||||
this.container.appendChild(label);
|
this.container.appendChild(label);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('upload-button', UploadButton);
|
customElements.define("upload-button", UploadButton);
|
||||||
customElements.define('tile-grid', TileGridElement);
|
customElements.define("tile-grid", TileGridElement);
|
||||||
|
|
||||||
class MeniaUploadElement extends HTMLElement {
|
class MeniaUploadElement extends HTMLElement {
|
||||||
constructor(){
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.component = document.createElement("div");
|
this.component = document.createElement("div");
|
||||||
alert('aaaa');
|
alert("aaaa");
|
||||||
this.shadowRoot.appendChild(this.component);
|
this.shadowRoot.appendChild(this.component);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this.container = document.createElement("div");
|
this.container = document.createElement("div");
|
||||||
this.component.style.height = '100%';
|
this.component.style.height = "100%";
|
||||||
this.component.style.backgroundColor = 'blue';
|
this.component.style.backgroundColor = "blue";
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
|
|
||||||
this.tileElement = document.createElement("tile-grid");
|
this.tileElement = document.createElement("tile-grid");
|
||||||
this.tileElement.style.backgroundColor = 'red';
|
this.tileElement.style.backgroundColor = "red";
|
||||||
this.tileElement.style.height = '100%';
|
this.tileElement.style.height = "100%";
|
||||||
this.component.appendChild(this.tileElement);
|
this.component.appendChild(this.tileElement);
|
||||||
|
|
||||||
this.uploadButton = document.createElement('upload-button');
|
this.uploadButton = document.createElement("upload-button");
|
||||||
this.component.appendChild(this.uploadButton);
|
this.component.appendChild(this.uploadButton);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('menia-upload', MeniaUploadElement);
|
customElements.define("menia-upload", MeniaUploadElement);
|
||||||
|
|||||||
@@ -22,23 +22,22 @@
|
|||||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
// SOFTWARE.
|
// SOFTWARE.
|
||||||
|
|
||||||
|
|
||||||
class MessageListManagerElement extends HTMLElement {
|
class MessageListManagerElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
this.container = document.createElement("div");
|
this.container = document.createElement("div");
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
}
|
}
|
||||||
|
|
||||||
async connectedCallback() {
|
async connectedCallback() {
|
||||||
const channels = await app.rpc.getChannels();
|
const channels = await app.rpc.getChannels();
|
||||||
channels.forEach(channel => {
|
channels.forEach((channel) => {
|
||||||
const messageList = document.createElement("message-list");
|
const messageList = document.createElement("message-list");
|
||||||
messageList.setAttribute("channel", channel.uid);
|
messageList.setAttribute("channel", channel.uid);
|
||||||
this.container.appendChild(messageList);
|
this.container.appendChild(messageList);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("message-list-manager", MessageListManagerElement);
|
customElements.define("message-list-manager", MessageListManagerElement);
|
||||||
+92
-215
@@ -5,226 +5,103 @@
|
|||||||
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
|
// The code seems to rely on some external dependencies like 'models.Message', 'app', and 'Schedule'. These should be imported or defined elsewhere in your application.
|
||||||
|
|
||||||
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
// MIT License: This is free software. Permission is granted to use, copy, modify, and/or distribute this software for any purpose with or without fee. The software is provided "as is" without any warranty.
|
||||||
import {app} from '../app.js'
|
import { app } from "../app.js";
|
||||||
class MessageList extends HTMLElement {
|
class MessageList extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
app.ws.addEventListener("update_message_text",(data)=>{
|
app.ws.addEventListener("update_message_text", (data) => {
|
||||||
this.updateMessageText(data.data.uid,data.data)
|
this.updateMessageText(data.uid, data);
|
||||||
})
|
});
|
||||||
app.ws.addEventListener("set_typing",(data)=>{
|
app.ws.addEventListener("set_typing", (data) => {
|
||||||
this.triggerGlow(data.data.user_uid)
|
this.triggerGlow(data.user_uid,data.color);
|
||||||
|
});
|
||||||
|
|
||||||
})
|
this.items = [];
|
||||||
|
}
|
||||||
|
|
||||||
this.items = [];
|
connectedCallback() {
|
||||||
|
const messagesContainer = this
|
||||||
|
messagesContainer.addEventListener('click', (e) => {
|
||||||
|
if (e.target.tagName !== 'IMG' || e.target.classList.contains('avatar-img')) return;
|
||||||
|
const img = e.target;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.9);display:flex;justify-content:center;align-items:center;z-index:9999;'
|
||||||
|
const fullImg = document.createElement('img');
|
||||||
|
const urlObj = new URL(img.src); urlObj.search = '';
|
||||||
|
fullImg.src = urlObj.toString();
|
||||||
|
fullImg.alt = img.alt;
|
||||||
|
fullImg.style.maxWidth = '90%';
|
||||||
|
fullImg.style.maxHeight = '90%';
|
||||||
|
overlay.appendChild(fullImg);
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
overlay.addEventListener('click', () => document.body.removeChild(overlay));
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
isElementVisible(element) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
rect.top >= 0 &&
|
||||||
|
rect.left >= 0 &&
|
||||||
|
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
|
||||||
|
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isScrolledToBottom() {
|
||||||
|
return this.isElementVisible(this.querySelector(".message-list-bottom"));
|
||||||
|
}
|
||||||
|
scrollToBottom(force) {
|
||||||
|
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) {
|
||||||
|
const messageDiv = this.querySelector('div[data-uid="' + uid + '"]');
|
||||||
|
|
||||||
|
if (!messageDiv) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const scrollToBottom = this.isScrolledToBottom();
|
||||||
|
const receivedHtml = document.createElement("div");
|
||||||
|
receivedHtml.innerHTML = message.html;
|
||||||
|
const html = receivedHtml.querySelector(".text").innerHTML;
|
||||||
|
const textElement = messageDiv.querySelector(".text");
|
||||||
|
textElement.innerHTML = html;
|
||||||
|
textElement.style.display = message.text == "" ? "none" : "block";
|
||||||
|
if(scrollToBottom)
|
||||||
|
this.scrollToBottom(true)
|
||||||
|
}
|
||||||
|
triggerGlow(uid,color) {
|
||||||
|
app.starField.glowColor(color)
|
||||||
|
let lastElement = null;
|
||||||
|
this.querySelectorAll(".avatar").forEach((el) => {
|
||||||
|
const div = el.closest("a");
|
||||||
|
if (el.href.indexOf(uid) != -1) {
|
||||||
|
lastElement = el;
|
||||||
}
|
}
|
||||||
updateMessageText(uid,message){
|
});
|
||||||
const messageDiv = this.querySelector("div[data-uid=\""+uid+"\"]")
|
if (lastElement) {
|
||||||
|
lastElement.classList.add("glow");
|
||||||
if(!messageDiv){
|
setTimeout(() => {
|
||||||
return
|
lastElement.classList.remove("glow");
|
||||||
}
|
}, 1000);
|
||||||
const receivedHtml = document.createElement("div")
|
|
||||||
receivedHtml.innerHTML = message.html
|
|
||||||
const html = receivedHtml.querySelector(".text").innerHTML
|
|
||||||
const textElement = messageDiv.querySelector(".text")
|
|
||||||
textElement.innerHTML = html
|
|
||||||
textElement.style.display = message.text == '' ? 'none' : 'block'
|
|
||||||
|
|
||||||
}
|
|
||||||
triggerGlow(uid) {
|
|
||||||
let lastElement = null;
|
|
||||||
this.querySelectorAll(".avatar").forEach((el)=>{
|
|
||||||
const div = el.closest('a');
|
|
||||||
if(el.href.indexOf(uid)!=-1){
|
|
||||||
lastElement = el
|
|
||||||
}
|
|
||||||
|
|
||||||
})
|
|
||||||
if(lastElement){
|
|
||||||
lastElement.classList.add("glow")
|
|
||||||
setTimeout(()=>{
|
|
||||||
lastElement.classList.remove("glow")
|
|
||||||
},1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
set data(items) {
|
|
||||||
this.items = items;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
render() {
|
|
||||||
this.innerHTML = '';
|
|
||||||
|
|
||||||
//this.insertAdjacentHTML("beforeend", html);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
customElements.define('message-list', MessageList);
|
set data(items) {
|
||||||
|
this.items = items;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
render() {
|
||||||
|
this.innerHTML = "";
|
||||||
|
|
||||||
class MessageListElementOLD extends HTMLElement {
|
//this.insertAdjacentHTML("beforeend", html);
|
||||||
static get observedAttributes() {
|
}
|
||||||
return ["messages"];
|
|
||||||
}
|
|
||||||
|
|
||||||
messages = [];
|
|
||||||
room = null;
|
|
||||||
url = null;
|
|
||||||
container = null;
|
|
||||||
messageEventSchedule = null;
|
|
||||||
observer = null;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.attachShadow({ mode: 'open' });
|
|
||||||
this.component = document.createElement('div');
|
|
||||||
this.shadowRoot.appendChild(this.component);
|
|
||||||
}
|
|
||||||
|
|
||||||
linkifyText(text) {
|
|
||||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
||||||
return text.replace(urlRegex, (url) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
timeAgo(date1, date2) {
|
|
||||||
const diffMs = Math.abs(date2 - date1);
|
|
||||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
|
||||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
|
||||||
const seconds = Math.floor((diffMs % (1000 * 60)) / 1000);
|
|
||||||
|
|
||||||
if (days) {
|
|
||||||
return `${days} ${days > 1 ? 'days' : 'day'} ago`;
|
|
||||||
}
|
|
||||||
if (hours) {
|
|
||||||
return `${hours} ${hours > 1 ? 'hours' : 'hour'} ago`;
|
|
||||||
}
|
|
||||||
if (minutes) {
|
|
||||||
return `${minutes} ${minutes > 1 ? 'minutes' : 'minute'} ago`;
|
|
||||||
}
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
timeDescription(isoDate) {
|
|
||||||
const date = new Date(isoDate);
|
|
||||||
const hours = String(date.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
let timeStr = `${hours}:${minutes}, ${this.timeAgo(new Date(isoDate), Date.now())}`;
|
|
||||||
return timeStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
createElement(message) {
|
|
||||||
const element = document.createElement("div");
|
|
||||||
element.dataset.uid = message.uid;
|
|
||||||
element.dataset.color = message.color;
|
|
||||||
element.dataset.channel_uid = message.channel_uid;
|
|
||||||
element.dataset.user_nick = message.user_nick;
|
|
||||||
element.dataset.created_at = message.created_at;
|
|
||||||
element.dataset.user_uid = message.user_uid;
|
|
||||||
element.dataset.message = message.message;
|
|
||||||
|
|
||||||
element.classList.add("message");
|
|
||||||
if (!this.messages.length || this.messages[this.messages.length - 1].user_uid != message.user_uid) {
|
|
||||||
element.classList.add("switch-user");
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatar = document.createElement("div");
|
|
||||||
avatar.classList.add("avatar");
|
|
||||||
avatar.classList.add("no-select");
|
|
||||||
avatar.style.backgroundColor = message.color;
|
|
||||||
avatar.style.color = "black";
|
|
||||||
avatar.innerText = message.user_nick[0];
|
|
||||||
|
|
||||||
const messageContent = document.createElement("div");
|
|
||||||
messageContent.classList.add("message-content");
|
|
||||||
|
|
||||||
const author = document.createElement("div");
|
|
||||||
author.classList.add("author");
|
|
||||||
author.style.color = message.color;
|
|
||||||
author.textContent = message.user_nick;
|
|
||||||
|
|
||||||
const text = document.createElement("div");
|
|
||||||
text.classList.add("text");
|
|
||||||
if (message.html) text.innerHTML = message.html;
|
|
||||||
|
|
||||||
const time = document.createElement("div");
|
|
||||||
time.classList.add("time");
|
|
||||||
time.dataset.created_at = message.created_at;
|
|
||||||
time.textContent = this.timeDescription(message.created_at);
|
|
||||||
|
|
||||||
messageContent.appendChild(author);
|
|
||||||
messageContent.appendChild(text);
|
|
||||||
messageContent.appendChild(time);
|
|
||||||
|
|
||||||
element.appendChild(avatar);
|
|
||||||
element.appendChild(messageContent);
|
|
||||||
|
|
||||||
message.element = element;
|
|
||||||
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
addMessage(message) {
|
|
||||||
const obj = new models.Message(
|
|
||||||
message.uid,
|
|
||||||
message.channel_uid,
|
|
||||||
message.user_uid,
|
|
||||||
message.user_nick,
|
|
||||||
message.color,
|
|
||||||
message.message,
|
|
||||||
message.html,
|
|
||||||
message.created_at,
|
|
||||||
message.updated_at
|
|
||||||
);
|
|
||||||
|
|
||||||
const element = this.createElement(obj);
|
|
||||||
this.messages.push(obj);
|
|
||||||
this.container.appendChild(element);
|
|
||||||
|
|
||||||
this.messageEventSchedule.delay(() => {
|
|
||||||
this.dispatchEvent(new CustomEvent("message", { detail: obj, bubbles: true }));
|
|
||||||
});
|
|
||||||
|
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollBottom() {
|
|
||||||
this.container.scrollTop = this.container.scrollHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
const link = document.createElement('link');
|
|
||||||
link.rel = 'stylesheet';
|
|
||||||
link.href = '/base.css';
|
|
||||||
this.component.appendChild(link);
|
|
||||||
this.component.classList.add("chat-messages");
|
|
||||||
|
|
||||||
this.container = document.createElement('div');
|
|
||||||
this.component.appendChild(this.container);
|
|
||||||
|
|
||||||
this.messageEventSchedule = new Schedule(500);
|
|
||||||
this.messages = [];
|
|
||||||
this.channel_uid = this.getAttribute("channel");
|
|
||||||
|
|
||||||
app.addEventListener(this.channel_uid, (data) => {
|
|
||||||
this.addMessage(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.dispatchEvent(new CustomEvent("rendered", { detail: this, bubbles: true }));
|
|
||||||
|
|
||||||
this.timeUpdateInterval = setInterval(() => {
|
|
||||||
this.messages.forEach((message) => {
|
|
||||||
const newText = this.timeDescription(message.created_at);
|
|
||||||
if (newText != message.element.innerText) {
|
|
||||||
message.element.querySelector(".time").innerText = newText;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//customElements.define('message-list', MessageListElement);
|
customElements.define("message-list", MessageList);
|
||||||
|
|||||||
+24
-14
@@ -7,20 +7,30 @@
|
|||||||
// MIT License
|
// MIT License
|
||||||
|
|
||||||
class MessageModel {
|
class MessageModel {
|
||||||
constructor(uid, channel_uid, user_uid, user_nick, color, message, html, created_at, updated_at) {
|
constructor(
|
||||||
this.uid = uid
|
uid,
|
||||||
this.message = message
|
channel_uid,
|
||||||
this.html = html
|
user_uid,
|
||||||
this.user_uid = user_uid
|
user_nick,
|
||||||
this.user_nick = user_nick
|
color,
|
||||||
this.color = color
|
message,
|
||||||
this.channel_uid = channel_uid
|
html,
|
||||||
this.created_at = created_at
|
created_at,
|
||||||
this.updated_at = updated_at
|
updated_at,
|
||||||
this.element = null
|
) {
|
||||||
}
|
this.uid = uid;
|
||||||
|
this.message = message;
|
||||||
|
this.html = html;
|
||||||
|
this.user_uid = user_uid;
|
||||||
|
this.user_nick = user_nick;
|
||||||
|
this.color = color;
|
||||||
|
this.channel_uid = channel_uid;
|
||||||
|
this.created_at = created_at;
|
||||||
|
this.updated_at = updated_at;
|
||||||
|
this.element = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const models = {
|
const models = {
|
||||||
Message: MessageModel
|
Message: MessageModel,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
|
|||||||
+32
-28
@@ -1,30 +1,34 @@
|
|||||||
this.onpush = (event) => {
|
this.onpush = (event) => {
|
||||||
console.log(event.data);
|
console.log(event.data);
|
||||||
// From here we can write the data to IndexedDB, send it to any open
|
// From here we can write the data to IndexedDB, send it to any open
|
||||||
// windows, display a notification, etc.
|
// windows, display a notification, etc.
|
||||||
};
|
};
|
||||||
|
|
||||||
navigator.serviceWorker
|
navigator.serviceWorker
|
||||||
.register("/service-worker.js")
|
.register("/service-worker.js")
|
||||||
.then((serviceWorkerRegistration) => {
|
.then((serviceWorkerRegistration) => {
|
||||||
serviceWorkerRegistration.pushManager.subscribe().then(
|
serviceWorkerRegistration.pushManager.subscribe().then(
|
||||||
(pushSubscription) => {
|
(pushSubscription) => {
|
||||||
const subscriptionObject = {
|
const subscriptionObject = {
|
||||||
endpoint: pushSubscription.endpoint,
|
endpoint: pushSubscription.endpoint,
|
||||||
keys: {
|
keys: {
|
||||||
p256dh: pushSubscription.getKey('p256dh'),
|
p256dh: pushSubscription.getKey("p256dh"),
|
||||||
auth: pushSubscription.getKey('auth'),
|
auth: pushSubscription.getKey("auth"),
|
||||||
},
|
},
|
||||||
encoding: PushManager.supportedContentEncodings,
|
encoding: PushManager.supportedContentEncodings,
|
||||||
/* other app-specific data, such as user identity */
|
/* other app-specific data, such as user identity */
|
||||||
};
|
};
|
||||||
console.log(pushSubscription.endpoint, pushSubscription, subscriptionObject);
|
console.log(
|
||||||
// The push subscription details needed by the application
|
pushSubscription.endpoint,
|
||||||
// server are now available, and can be sent to it using,
|
pushSubscription,
|
||||||
// for example, the fetch() API.
|
subscriptionObject,
|
||||||
},
|
);
|
||||||
(error) => {
|
// The push subscription details needed by the application
|
||||||
console.error(error);
|
// server are now available, and can be sent to it using,
|
||||||
},
|
// for example, the fetch() API.
|
||||||
);
|
},
|
||||||
});
|
(error) => {
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
async function requestNotificationPermission() {
|
async function requestNotificationPermission() {
|
||||||
const permission = await Notification.requestPermission();
|
const permission = await Notification.requestPermission();
|
||||||
return permission === 'granted';
|
return permission === "granted";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe to Push Notifications
|
// Subscribe to Push Notifications
|
||||||
async function subscribeUser() {
|
async function subscribeUser() {
|
||||||
const registration = await navigator.serviceWorker.register('/service-worker.js');
|
const registration =
|
||||||
|
await navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
|
||||||
const subscription = await registration.pushManager.subscribe({
|
const subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY)
|
applicationServerKey: urlBase64ToUint8Array(PUBLIC_VAPID_KEY),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send subscription to your backend
|
// Send subscription to your backend
|
||||||
await fetch('/subscribe', {
|
await fetch("/subscribe", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: JSON.stringify(subscription),
|
body: JSON.stringify(subscription),
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
"Content-Type": "application/json",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service Worker (service-worker.js)
|
// Service Worker (service-worker.js)
|
||||||
self.addEventListener('push', event => {
|
self.addEventListener("push", (event) => {
|
||||||
const data = event.data.json();
|
const data = event.data.json();
|
||||||
self.registration.showNotification(data.title, {
|
self.registration.showNotification(data.title, {
|
||||||
body: data.message,
|
body: data.message,
|
||||||
icon: data.icon
|
icon: data.icon,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+130
-121
@@ -1,142 +1,151 @@
|
|||||||
import {EventHandler} from "./event-handler.js";
|
import { EventHandler } from "./event-handler.js";
|
||||||
|
|
||||||
export class Socket extends EventHandler {
|
export class Socket extends EventHandler {
|
||||||
/**
|
/**
|
||||||
* @type {URL}
|
* @type {URL}
|
||||||
*/
|
*/
|
||||||
url
|
url;
|
||||||
/**
|
/**
|
||||||
* @type {WebSocket|null}
|
* @type {WebSocket|null}
|
||||||
*/
|
*/
|
||||||
ws = null
|
ws = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
|
* @type {null|PromiseWithResolvers<Socket>&{resolved?:boolean}}
|
||||||
*/
|
*/
|
||||||
connection = null
|
connection = null;
|
||||||
|
|
||||||
shouldReconnect = true;
|
shouldReconnect = true;
|
||||||
|
|
||||||
get isConnected() {
|
_debug = false;
|
||||||
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
||||||
|
get isConnected() {
|
||||||
|
return this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isConnecting() {
|
||||||
|
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.url = new URL("/rpc.ws", window.location.origin);
|
||||||
|
this.url.protocol = this.url.protocol.replace("http", "ws");
|
||||||
|
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.ws) {
|
||||||
|
return this.connection.promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConnecting() {
|
if (!this.connection || this.connection.resolved) {
|
||||||
return this.ws && this.ws.readyState === WebSocket.CONNECTING;
|
this.connection = Promise.withResolvers();
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
this.ws = new WebSocket(this.url);
|
||||||
super();
|
this.ws.addEventListener("open", () => {
|
||||||
|
this.connection.resolved = true;
|
||||||
|
this.connection.resolve(this);
|
||||||
|
this.emit("connected");
|
||||||
|
});
|
||||||
|
|
||||||
this.url = new URL('/rpc.ws', window.location.origin);
|
this.ws.addEventListener("close", () => {
|
||||||
this.url.protocol = this.url.protocol.replace('http', 'ws');
|
console.log("Connection closed");
|
||||||
|
this.disconnect();
|
||||||
this.connect()
|
});
|
||||||
}
|
this.ws.addEventListener("error", (e) => {
|
||||||
|
console.error("Connection error", e);
|
||||||
connect() {
|
this.disconnect();
|
||||||
if (this.ws) {
|
});
|
||||||
return this.connection.promise;
|
this.ws.addEventListener("message", (e) => {
|
||||||
|
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
||||||
|
console.error("Binary data not supported");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
this.onData(JSON.parse(e.data));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to parse message", e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.connection || this.connection.resolved) {
|
onData(data) {
|
||||||
this.connection = Promise.withResolvers()
|
if (data.success !== undefined && !data.success) {
|
||||||
}
|
console.error(data);
|
||||||
|
|
||||||
this.ws = new WebSocket(this.url);
|
|
||||||
this.ws.addEventListener("open", () => {
|
|
||||||
this.connection.resolved = true;
|
|
||||||
this.connection.resolve(this);
|
|
||||||
this.emit("connected");
|
|
||||||
});
|
|
||||||
|
|
||||||
this.ws.addEventListener("close", () => {
|
|
||||||
console.log("Connection closed");
|
|
||||||
this.disconnect()
|
|
||||||
})
|
|
||||||
this.ws.addEventListener("error", (e) => {
|
|
||||||
console.error("Connection error", e);
|
|
||||||
this.disconnect()
|
|
||||||
})
|
|
||||||
this.ws.addEventListener("message", (e) => {
|
|
||||||
if (e.data instanceof Blob || e.data instanceof ArrayBuffer) {
|
|
||||||
console.error("Binary data not supported");
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
this.onData(JSON.parse(e.data));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse message", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
if (data.callId) {
|
||||||
|
this.emit(data.callId, data.data);
|
||||||
onData(data) {
|
|
||||||
if (data.success !== undefined && !data.success) {
|
|
||||||
console.error(data);
|
|
||||||
}
|
|
||||||
if (data.callId) {
|
|
||||||
this.emit(data.callId, data.data);
|
|
||||||
}
|
|
||||||
if (data.channel_uid) {
|
|
||||||
this.emit(data.channel_uid, data.data);
|
|
||||||
if(!data['event'])
|
|
||||||
this.emit("channel-message", data);
|
|
||||||
}
|
|
||||||
this.emit("data", data.data)
|
|
||||||
if(data['event']){
|
|
||||||
this.emit(data.event, data)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (data.channel_uid) {
|
||||||
disconnect() {
|
this.emit(data.channel_uid, data.data);
|
||||||
this.ws?.close();
|
if (!data["event"]) this.emit("channel-message", data);
|
||||||
this.ws = null;
|
|
||||||
|
|
||||||
if (this.shouldReconnect) setTimeout(() => {
|
|
||||||
console.log("Reconnecting");
|
|
||||||
return this.connect();
|
|
||||||
}, 0);
|
|
||||||
}
|
}
|
||||||
|
this.emit("data", data.data);
|
||||||
|
if (data["event"]) {
|
||||||
_camelToSnake(str) {
|
this.emit(data.event, data.data);
|
||||||
return str.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get client() {
|
disconnect() {
|
||||||
const me = this;
|
this.ws?.close();
|
||||||
return new Proxy({}, {
|
this.ws = null;
|
||||||
get(_, prop) {
|
|
||||||
return (...args) => {
|
|
||||||
const functionName = me._camelToSnake(prop);
|
|
||||||
return me.call(functionName, ...args);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
generateCallId() {
|
if (this.shouldReconnect)
|
||||||
return self.crypto.randomUUID();
|
setTimeout(() => {
|
||||||
}
|
console.log("Reconnecting");
|
||||||
|
this.emit("reconnecting");
|
||||||
|
return this.connect();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
async sendJson(data) {
|
_camelToSnake(str) {
|
||||||
await this.connect().then(api => {
|
return str.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
|
||||||
api.ws.send(JSON.stringify(data));
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async call(method, ...args) {
|
get client() {
|
||||||
const call = {
|
const me = this;
|
||||||
callId: this.generateCallId(),
|
return new Proxy(
|
||||||
method,
|
{},
|
||||||
args,
|
{
|
||||||
};
|
get(_, prop) {
|
||||||
const me = this
|
return (...args) => {
|
||||||
return new Promise((resolve) => {
|
const functionName = me._camelToSnake(prop);
|
||||||
me.addEventListener(call.callId, data => resolve(data));
|
if(me._debug){
|
||||||
me.sendJson(call);
|
const call = {}
|
||||||
});
|
call[functionName] = args
|
||||||
}
|
console.debug(call)
|
||||||
|
}
|
||||||
|
return me.call(functionName, ...args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
generateCallId() {
|
||||||
|
return self.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendJson(data) {
|
||||||
|
await this.connect().then((api) => {
|
||||||
|
api.ws.send(JSON.stringify(data));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async call(method, ...args) {
|
||||||
|
const call = {
|
||||||
|
callId: this.generateCallId(),
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
const me = this;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
me.addEventListener(call.callId, (data) => resolve(data));
|
||||||
|
me.sendJson(call);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,61 +2,62 @@
|
|||||||
|
|
||||||
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
|
// This class defines a custom HTML element for an upload button with integrated file upload functionality using XMLHttpRequest.
|
||||||
|
|
||||||
|
|
||||||
// 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:
|
// 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:
|
||||||
|
|
||||||
class UploadButtonElement extends HTMLElement {
|
class UploadButtonElement extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.attachShadow({ mode: 'open' });
|
this.attachShadow({ mode: "open" });
|
||||||
|
}
|
||||||
|
chatInput = null;
|
||||||
|
async uploadFiles() {
|
||||||
|
const fileInput = this.container.querySelector(".file-input");
|
||||||
|
const uploadButton = this.container.querySelector(".upload-button");
|
||||||
|
|
||||||
|
if (!fileInput.files.length) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
chatInput = null
|
|
||||||
async uploadFiles() {
|
|
||||||
const fileInput = this.container.querySelector('.file-input');
|
|
||||||
const uploadButton = this.container.querySelector('.upload-button');
|
|
||||||
|
|
||||||
if (!fileInput.files.length) {
|
const files = fileInput.files;
|
||||||
return;
|
const formData = new FormData();
|
||||||
}
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
formData.append("files[]", files[i]);
|
||||||
const files = fileInput.files;
|
|
||||||
const formData = new FormData();
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
formData.append('files[]', files[i]);
|
|
||||||
}
|
|
||||||
const request = new XMLHttpRequest();
|
|
||||||
|
|
||||||
request.responseType = 'json';
|
|
||||||
request.open('POST', `/channel/${this.channelUid}/attachment.bin`, true);
|
|
||||||
|
|
||||||
request.upload.onprogress = function (event) {
|
|
||||||
if (event.lengthComputable) {
|
|
||||||
const percentComplete = (event.loaded / event.total) * 100;
|
|
||||||
uploadButton.innerText = `${Math.round(percentComplete)}%`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const me = this
|
|
||||||
request.onload = function () {
|
|
||||||
if (request.status === 200) {
|
|
||||||
me.dispatchEvent(new CustomEvent('uploaded', { detail: request.response }));
|
|
||||||
uploadButton.innerHTML = '📤';
|
|
||||||
} else {
|
|
||||||
alert('Upload failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.onerror = function () {
|
|
||||||
alert('Error while uploading.');
|
|
||||||
};
|
|
||||||
|
|
||||||
request.send(formData);
|
|
||||||
const uploadEvent = new Event('upload',{});
|
|
||||||
this.dispatchEvent(uploadEvent);
|
|
||||||
}
|
}
|
||||||
channelUid = null
|
const request = new XMLHttpRequest();
|
||||||
connectedCallback() {
|
|
||||||
this.styleElement = document.createElement('style');
|
request.responseType = "json";
|
||||||
this.styleElement.innerHTML = `
|
request.open("POST", `/channel/${this.channelUid}/attachment.bin`, true);
|
||||||
|
|
||||||
|
request.upload.onprogress = function (event) {
|
||||||
|
if (event.lengthComputable) {
|
||||||
|
const percentComplete = (event.loaded / event.total) * 100;
|
||||||
|
uploadButton.innerText = `${Math.round(percentComplete)}%`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const me = this;
|
||||||
|
request.onload = function () {
|
||||||
|
if (request.status === 200) {
|
||||||
|
me.dispatchEvent(
|
||||||
|
new CustomEvent("uploaded", { detail: request.response }),
|
||||||
|
);
|
||||||
|
uploadButton.innerHTML = "📤";
|
||||||
|
} else {
|
||||||
|
alert("Upload failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = function () {
|
||||||
|
alert("Error while uploading.");
|
||||||
|
};
|
||||||
|
|
||||||
|
request.send(formData);
|
||||||
|
const uploadEvent = new Event("upload", {});
|
||||||
|
this.dispatchEvent(uploadEvent);
|
||||||
|
}
|
||||||
|
channelUid = null;
|
||||||
|
connectedCallback() {
|
||||||
|
this.styleElement = document.createElement("style");
|
||||||
|
this.styleElement.innerHTML = `
|
||||||
body {
|
body {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -97,9 +98,9 @@ class UploadButtonElement extends HTMLElement {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.styleElement);
|
this.shadowRoot.appendChild(this.styleElement);
|
||||||
this.container = document.createElement('div');
|
this.container = document.createElement("div");
|
||||||
this.container.innerHTML = `
|
this.container.innerHTML = `
|
||||||
<div class="upload-container">
|
<div class="upload-container">
|
||||||
<button class="upload-button">
|
<button class="upload-button">
|
||||||
📤
|
📤
|
||||||
@@ -107,17 +108,17 @@ class UploadButtonElement extends HTMLElement {
|
|||||||
<input class="hidden-input file-input" type="file" multiple />
|
<input class="hidden-input file-input" type="file" multiple />
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.shadowRoot.appendChild(this.container);
|
this.shadowRoot.appendChild(this.container);
|
||||||
this.channelUid = this.getAttribute('channel');
|
this.channelUid = this.getAttribute("channel");
|
||||||
this.uploadButton = this.container.querySelector('.upload-button');
|
this.uploadButton = this.container.querySelector(".upload-button");
|
||||||
this.fileInput = this.container.querySelector('.hidden-input');
|
this.fileInput = this.container.querySelector(".hidden-input");
|
||||||
this.uploadButton.addEventListener('click', () => {
|
this.uploadButton.addEventListener("click", () => {
|
||||||
this.fileInput.click();
|
this.fileInput.click();
|
||||||
});
|
});
|
||||||
this.fileInput.addEventListener('change', () => {
|
this.fileInput.addEventListener("change", () => {
|
||||||
this.uploadFiles();
|
this.uploadFiles();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('upload-button', UploadButtonElement);
|
customElements.define("upload-button", UploadButtonElement);
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
class UserList extends HTMLElement {
|
class UserList extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.users = [];
|
this.users = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
set data(userArray) {
|
set data(userArray) {
|
||||||
this.users = userArray;
|
this.users = userArray;
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
formatRelativeTime(timestamp) {
|
formatRelativeTime(timestamp) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const msgTime = new Date(timestamp);
|
const msgTime = new Date(timestamp);
|
||||||
const diffMs = now - msgTime;
|
const diffMs = now - msgTime;
|
||||||
const minutes = Math.floor(diffMs / 60000);
|
const minutes = Math.floor(diffMs / 60000);
|
||||||
const hours = Math.floor(minutes / 60);
|
const hours = Math.floor(minutes / 60);
|
||||||
const days = Math.floor(hours / 24);
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
if (days > 0) {
|
if (days > 0) {
|
||||||
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${days} day${days > 1 ? 's' : ''} ago`;
|
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${days} day${days > 1 ? "s" : ""} ago`;
|
||||||
} else if (hours > 0) {
|
} else if (hours > 0) {
|
||||||
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${hours} hour${hours > 1 ? 's' : ''} ago`;
|
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${hours} hour${hours > 1 ? "s" : ""} ago`;
|
||||||
} else {
|
} else {
|
||||||
return `${msgTime.getHours().toString().padStart(2, '0')}:${msgTime.getMinutes().toString().padStart(2, '0')}, ${minutes} min ago`;
|
return `${msgTime.getHours().toString().padStart(2, "0")}:${msgTime.getMinutes().toString().padStart(2, "0")}, ${minutes} min ago`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
this.innerHTML = '';
|
this.innerHTML = "";
|
||||||
|
|
||||||
this.users.forEach(user => {
|
this.users.forEach((user) => {
|
||||||
const html = `
|
const html = `
|
||||||
<div class="user-list__item"
|
<div class="user-list__item"
|
||||||
data-uid="${user.uid}"
|
data-uid="${user.uid}"
|
||||||
data-color="${user.color}"
|
data-color="${user.color}"
|
||||||
@@ -51,9 +51,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
this.insertAdjacentHTML("beforeend", html);
|
this.insertAdjacentHTML("beforeend", html);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('user-list', UserList);
|
customElements.define("user-list", UserList);
|
||||||
|
|||||||
@@ -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.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>
|
||||||
|
|||||||
+233
-267
@@ -6,323 +6,289 @@
|
|||||||
|
|
||||||
<section class="chat-area">
|
<section class="chat-area">
|
||||||
<message-list class="chat-messages">
|
<message-list class="chat-messages">
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
{% autoescape false %}
|
{% autoescape false %}
|
||||||
{{ message.html }}
|
{{ message.html }}
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
<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) {
|
|
||||||
lastMessage?.scrollIntoView({ block: "end", inline: "nearest" });
|
|
||||||
|
|
||||||
chatInputField.scrollIntoView({ block: "end", inline: "nearest" });
|
|
||||||
}
|
}
|
||||||
}
|
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