Compare commits
No commits in common. "main" and "v1-desktop" have entirely different histories.
main
...
v1-desktop
@ -1,4 +1,3 @@
|
|||||||
DOMAIN_URL=
|
|
||||||
ADMIN_USERNAME=
|
ADMIN_USERNAME=
|
||||||
ADMIN_PASSWORD=
|
ADMIN_PASSWORD=
|
||||||
PRIVATE_ACCESS_KEY=333
|
PRIVATE_ACCESS_KEY=333
|
||||||
|
|||||||
335
attachments.py
335
attachments.py
@ -1,335 +0,0 @@
|
|||||||
from datetime import timedelta
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import abort, jsonify, send_from_directory
|
|
||||||
|
|
||||||
from db import get_db
|
|
||||||
|
|
||||||
|
|
||||||
IMAGE_MIME_PREFIX = "image/"
|
|
||||||
AUDIO_MIME_PREFIX = "audio/"
|
|
||||||
VIDEO_MIME_PREFIX = "video/"
|
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
|
|
||||||
AUDIO_EXTENSIONS = {"mp3", "wav", "ogg", "m4a", "aac", "flac"}
|
|
||||||
VIDEO_EXTENSIONS = {"mp4", "webm", "mov", "m4v", "ogv"}
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_kind_from_name_and_mime(
|
|
||||||
original_name: Optional[str],
|
|
||||||
mime_type: Optional[str],
|
|
||||||
) -> str:
|
|
||||||
mime_type = str(mime_type or "").lower()
|
|
||||||
name = str(original_name or "")
|
|
||||||
ext = name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
||||||
if mime_type.startswith(IMAGE_MIME_PREFIX) or ext in IMAGE_EXTENSIONS:
|
|
||||||
return "image"
|
|
||||||
if mime_type.startswith(AUDIO_MIME_PREFIX) or ext in AUDIO_EXTENSIONS:
|
|
||||||
return "audio"
|
|
||||||
if mime_type.startswith(VIDEO_MIME_PREFIX) or ext in VIDEO_EXTENSIONS:
|
|
||||||
return "video"
|
|
||||||
return "file"
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_payload_from_row(row) -> Optional[dict]:
|
|
||||||
if not row or not row["id"]:
|
|
||||||
return None
|
|
||||||
kind = attachment_kind_from_name_and_mime(
|
|
||||||
row["original_name"],
|
|
||||||
row["mime_type"],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"original_name": row["original_name"],
|
|
||||||
"mime_type": row["mime_type"],
|
|
||||||
"size_bytes": row["size_bytes"],
|
|
||||||
"kind": kind,
|
|
||||||
"url": f"/api/attachments/{row['id']}/download",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_payload_legacy_from_message_select_row(row) -> Optional[dict]:
|
|
||||||
if not row["attachment_id"]:
|
|
||||||
return None
|
|
||||||
kind = attachment_kind_from_name_and_mime(
|
|
||||||
row["attachment_original_name"],
|
|
||||||
row["attachment_mime_type"],
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"id": row["attachment_id"],
|
|
||||||
"original_name": row["attachment_original_name"],
|
|
||||||
"mime_type": row["attachment_mime_type"],
|
|
||||||
"size_bytes": row["attachment_size_bytes"],
|
|
||||||
"kind": kind,
|
|
||||||
"url": f"/api/attachments/{row['attachment_id']}/download",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def attachment_list_for_message(message_id: int) -> list[dict]:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
a.id,
|
|
||||||
a.original_name,
|
|
||||||
a.mime_type,
|
|
||||||
a.size_bytes
|
|
||||||
FROM message_attachments AS ma
|
|
||||||
JOIN attachments AS a
|
|
||||||
ON a.id = ma.attachment_id
|
|
||||||
WHERE ma.message_id = ?
|
|
||||||
ORDER BY ma.position ASC, a.id ASC
|
|
||||||
""",
|
|
||||||
(message_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [attachment_payload_from_row(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_orphaned_attachments(
|
|
||||||
upload_dir: Path,
|
|
||||||
utc_now_func,
|
|
||||||
orphan_ttl_hours: int,
|
|
||||||
app_logger,
|
|
||||||
) -> None:
|
|
||||||
cutoff = (utc_now_func() - timedelta(hours=orphan_ttl_hours)).isoformat()
|
|
||||||
with get_db() as db:
|
|
||||||
orphaned = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
a.id,
|
|
||||||
a.stored_name
|
|
||||||
FROM attachments AS a
|
|
||||||
WHERE a.created_at < ?
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM messages AS m
|
|
||||||
WHERE m.attachment_id = a.id
|
|
||||||
)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM message_attachments AS ma
|
|
||||||
WHERE ma.attachment_id = a.id
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(cutoff,),
|
|
||||||
).fetchall()
|
|
||||||
for attachment in orphaned:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
DELETE FROM attachments
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(attachment["id"],),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
for attachment in orphaned:
|
|
||||||
try:
|
|
||||||
(upload_dir / attachment["stored_name"]).unlink(missing_ok=True)
|
|
||||||
except OSError:
|
|
||||||
app_logger.warning(
|
|
||||||
"Could not remove orphaned attachment file: %s",
|
|
||||||
attachment["stored_name"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def maybe_cleanup_orphaned_attachments(
|
|
||||||
state: dict,
|
|
||||||
cleanup_interval_seconds: int,
|
|
||||||
cleanup_func,
|
|
||||||
app_logger,
|
|
||||||
) -> None:
|
|
||||||
import time
|
|
||||||
|
|
||||||
now_value = time.time()
|
|
||||||
last = state.get("last_attachment_cleanup", 0.0)
|
|
||||||
if now_value - last < cleanup_interval_seconds:
|
|
||||||
return
|
|
||||||
state["last_attachment_cleanup"] = now_value
|
|
||||||
try:
|
|
||||||
cleanup_func()
|
|
||||||
except Exception:
|
|
||||||
app_logger.exception("Could not clean orphaned attachments.")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_owned_attachment_ids(
|
|
||||||
attachment_ids: object,
|
|
||||||
user_id: int,
|
|
||||||
) -> tuple[Optional[list[int]], Optional[tuple]]:
|
|
||||||
if attachment_ids is None:
|
|
||||||
return [], None
|
|
||||||
if not isinstance(attachment_ids, list):
|
|
||||||
return None, (jsonify({"error": "attachment_ids must be an array."}), 400)
|
|
||||||
|
|
||||||
parsed = []
|
|
||||||
seen = set()
|
|
||||||
for item in attachment_ids[:10]:
|
|
||||||
try:
|
|
||||||
attachment_id = int(item)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
|
||||||
if attachment_id in seen:
|
|
||||||
continue
|
|
||||||
seen.add(attachment_id)
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, uploader_user_id
|
|
||||||
FROM attachments
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(attachment_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None, (jsonify({"error": "Attachment not found."}), 404)
|
|
||||||
if row["uploader_user_id"] != user_id:
|
|
||||||
return None, (
|
|
||||||
jsonify({"error": "You can only attach files you uploaded."}),
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
parsed.append(attachment_id)
|
|
||||||
return parsed, None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_owned_attachment(
|
|
||||||
attachment_id: object,
|
|
||||||
user_id: int,
|
|
||||||
) -> tuple[Optional[int], Optional[tuple]]:
|
|
||||||
if attachment_id is None:
|
|
||||||
return None, None
|
|
||||||
try:
|
|
||||||
parsed_id = int(attachment_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return None, (jsonify({"error": "Invalid attachment."}), 400)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
attachment = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
uploader_user_id
|
|
||||||
FROM attachments
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(parsed_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not attachment:
|
|
||||||
return None, (jsonify({"error": "Attachment not found."}), 404)
|
|
||||||
if attachment["uploader_user_id"] != user_id:
|
|
||||||
return None, (
|
|
||||||
jsonify({"error": "You can only attach files you uploaded."}),
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
return parsed_id, None
|
|
||||||
|
|
||||||
|
|
||||||
def can_download_attachment(
|
|
||||||
attachment_id: int,
|
|
||||||
user: Optional[dict],
|
|
||||||
can_access_room,
|
|
||||||
can_access_channel,
|
|
||||||
get_channel_by_id,
|
|
||||||
) -> bool:
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
with get_db() as db:
|
|
||||||
room_rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT
|
|
||||||
r.id,
|
|
||||||
r.slug,
|
|
||||||
r.name,
|
|
||||||
r.description,
|
|
||||||
r.visibility,
|
|
||||||
r.created_by_user_id,
|
|
||||||
r.is_active
|
|
||||||
FROM message_attachments AS ma
|
|
||||||
JOIN messages AS m
|
|
||||||
ON m.id = ma.message_id
|
|
||||||
JOIN rooms AS r
|
|
||||||
ON r.id = m.room_id
|
|
||||||
WHERE ma.attachment_id = ?
|
|
||||||
AND r.is_active = 1
|
|
||||||
""",
|
|
||||||
(attachment_id,),
|
|
||||||
).fetchall()
|
|
||||||
channel_rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT
|
|
||||||
m.channel_id
|
|
||||||
FROM message_attachments AS ma
|
|
||||||
JOIN messages AS m
|
|
||||||
ON m.id = ma.message_id
|
|
||||||
WHERE ma.attachment_id = ?
|
|
||||||
AND m.channel_id IS NOT NULL
|
|
||||||
""",
|
|
||||||
(attachment_id,),
|
|
||||||
).fetchall()
|
|
||||||
dm_rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT DISTINCT dc.id
|
|
||||||
FROM message_attachments AS ma
|
|
||||||
JOIN messages AS m
|
|
||||||
ON m.id = ma.message_id
|
|
||||||
JOIN direct_conversations AS dc
|
|
||||||
ON dc.id = m.direct_conversation_id
|
|
||||||
JOIN direct_conversation_members AS dcm
|
|
||||||
ON dcm.direct_conversation_id = dc.id
|
|
||||||
WHERE ma.attachment_id = ?
|
|
||||||
AND dcm.user_id = ?
|
|
||||||
""",
|
|
||||||
(attachment_id, user["id"]),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if dm_rows:
|
|
||||||
return True
|
|
||||||
if any(can_access_room(room, user) for room in room_rows):
|
|
||||||
return True
|
|
||||||
for item in channel_rows:
|
|
||||||
channel = get_channel_by_id(item["channel_id"])
|
|
||||||
if channel and can_access_channel(channel, user):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def send_attachment_file(
|
|
||||||
upload_dir: Path,
|
|
||||||
attachment_id: int,
|
|
||||||
user: Optional[dict],
|
|
||||||
can_access_room,
|
|
||||||
can_access_channel,
|
|
||||||
get_channel_by_id,
|
|
||||||
):
|
|
||||||
with get_db() as db:
|
|
||||||
attachment = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
stored_name,
|
|
||||||
original_name,
|
|
||||||
mime_type
|
|
||||||
FROM attachments
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(attachment_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not attachment:
|
|
||||||
abort(404)
|
|
||||||
|
|
||||||
if not can_download_attachment(
|
|
||||||
attachment_id,
|
|
||||||
user,
|
|
||||||
can_access_room,
|
|
||||||
can_access_channel,
|
|
||||||
get_channel_by_id,
|
|
||||||
):
|
|
||||||
abort(403)
|
|
||||||
|
|
||||||
return send_from_directory(
|
|
||||||
upload_dir,
|
|
||||||
attachment["stored_name"],
|
|
||||||
as_attachment=True,
|
|
||||||
download_name=attachment["original_name"],
|
|
||||||
mimetype=attachment["mime_type"],
|
|
||||||
)
|
|
||||||
498
auth.py
498
auth.py
@ -1,498 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
import hmac
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import secrets
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from flask import jsonify, request, session
|
|
||||||
from werkzeug.exceptions import BadRequest
|
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
|
||||||
|
|
||||||
from config import (
|
|
||||||
CLIENT_HASH_KEY,
|
|
||||||
PLACEHOLDER_SECRETS,
|
|
||||||
PRIVATE_ACCESS_KEY,
|
|
||||||
RATE_LIMITS,
|
|
||||||
ROLES,
|
|
||||||
STATUSES,
|
|
||||||
MAX_PASSWORD_LENGTH,
|
|
||||||
MIN_PASSWORD_LENGTH,
|
|
||||||
MAX_USERNAME_LENGTH,
|
|
||||||
MIN_USERNAME_LENGTH,
|
|
||||||
now_iso,
|
|
||||||
parse_iso,
|
|
||||||
utc_now,
|
|
||||||
)
|
|
||||||
from db import db_write, get_db
|
|
||||||
|
|
||||||
USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_username(value: object) -> str:
|
|
||||||
return value.strip().lower() if isinstance(value, str) else ""
|
|
||||||
|
|
||||||
|
|
||||||
def validate_username(username: str) -> Optional[str]:
|
|
||||||
if not username:
|
|
||||||
return "Username is required."
|
|
||||||
if len(username) < MIN_USERNAME_LENGTH:
|
|
||||||
return f"Username must be at least {MIN_USERNAME_LENGTH} characters."
|
|
||||||
if len(username) > MAX_USERNAME_LENGTH:
|
|
||||||
return f"Username must be {MAX_USERNAME_LENGTH} characters or less."
|
|
||||||
if not USERNAME_RE.fullmatch(username):
|
|
||||||
return "Username contains invalid characters."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def validate_password(password: object, username: str = "") -> Optional[str]:
|
|
||||||
if not isinstance(password, str) or not password:
|
|
||||||
return "Password is required."
|
|
||||||
if len(password) < MIN_PASSWORD_LENGTH:
|
|
||||||
return f"Password must be at least {MIN_PASSWORD_LENGTH} characters."
|
|
||||||
if len(password) > MAX_PASSWORD_LENGTH:
|
|
||||||
return f"Password must be {MAX_PASSWORD_LENGTH} characters or less."
|
|
||||||
if not re.search(r"[a-z]", password):
|
|
||||||
return "Password must include a lowercase letter."
|
|
||||||
if not re.search(r"[A-Z]", password):
|
|
||||||
return "Password must include an uppercase letter."
|
|
||||||
if not re.search(r"\d", password):
|
|
||||||
return "Password must include a number."
|
|
||||||
if not re.search(r"[^A-Za-z0-9]", password):
|
|
||||||
return "Password must include a symbol."
|
|
||||||
if username and username in password.lower():
|
|
||||||
return "Password must not contain your username."
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def safe_json() -> dict:
|
|
||||||
if not request.is_json:
|
|
||||||
raise ValueError("Request body must be JSON.")
|
|
||||||
try:
|
|
||||||
data = request.get_json(silent=False)
|
|
||||||
except BadRequest as error:
|
|
||||||
raise ValueError("Invalid JSON request body.") from error
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise ValueError("JSON body must be an object.")
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_token() -> str:
|
|
||||||
token = session.get("csrf_token")
|
|
||||||
if not token:
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
session["csrf_token"] = token
|
|
||||||
return token
|
|
||||||
|
|
||||||
|
|
||||||
def csrf_valid() -> bool:
|
|
||||||
supplied = request.headers.get("X-CSRF-Token", "")
|
|
||||||
expected = session.get("csrf_token", "")
|
|
||||||
return bool(supplied and expected and hmac.compare_digest(supplied, expected))
|
|
||||||
|
|
||||||
|
|
||||||
def client_ip() -> str:
|
|
||||||
return (request.remote_addr or "unknown")[:128]
|
|
||||||
|
|
||||||
|
|
||||||
def ctx_hash():
|
|
||||||
return hashlib.sha256
|
|
||||||
|
|
||||||
|
|
||||||
def hash_identifier(value: str) -> str:
|
|
||||||
return hmac.new(
|
|
||||||
CLIENT_HASH_KEY.encode("utf-8"),
|
|
||||||
value.encode("utf-8"),
|
|
||||||
ctx_hash(),
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def b64url_encode_bytes(data: bytes) -> str:
|
|
||||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
||||||
|
|
||||||
|
|
||||||
def sign_call_helper_token(payload: dict, shared_secret: str) -> str:
|
|
||||||
payload_json = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
|
|
||||||
payload_bytes = payload_json.encode("utf-8")
|
|
||||||
signature = hmac.new(
|
|
||||||
shared_secret.encode("utf-8"),
|
|
||||||
payload_bytes,
|
|
||||||
hashlib.sha256,
|
|
||||||
).digest()
|
|
||||||
return f"{b64url_encode_bytes(payload_bytes)}.{b64url_encode_bytes(signature)}"
|
|
||||||
|
|
||||||
|
|
||||||
def current_client_hash() -> str:
|
|
||||||
return hash_identifier(client_ip())
|
|
||||||
|
|
||||||
|
|
||||||
def audit(
|
|
||||||
action: str,
|
|
||||||
actor_user_id: Optional[int] = None,
|
|
||||||
target_type: Optional[str] = None,
|
|
||||||
target_id: Optional[str] = None,
|
|
||||||
details: Optional[str] = None,
|
|
||||||
) -> None:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO audit_log (
|
|
||||||
actor_user_id,
|
|
||||||
action,
|
|
||||||
target_type,
|
|
||||||
target_id,
|
|
||||||
details,
|
|
||||||
client_hash,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
actor_user_id,
|
|
||||||
action,
|
|
||||||
target_type,
|
|
||||||
target_id,
|
|
||||||
details,
|
|
||||||
current_client_hash(),
|
|
||||||
now_iso(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def active_blacklist_match(kind: str, value_hash: str) -> bool:
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM blacklist_entries
|
|
||||||
WHERE kind = ?
|
|
||||||
AND value_hash = ?
|
|
||||||
AND (
|
|
||||||
expires_at IS NULL
|
|
||||||
OR expires_at > ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(kind, value_hash, now_iso()),
|
|
||||||
).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
def request_blacklisted() -> bool:
|
|
||||||
if request.endpoint in {"static"}:
|
|
||||||
return False
|
|
||||||
if active_blacklist_match("ip", current_client_hash()):
|
|
||||||
return True
|
|
||||||
user_id = session.get("user_id")
|
|
||||||
if user_id and active_blacklist_match("user", hash_identifier(str(user_id))):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def check_rate_limit(scope: str) -> Optional[int]:
|
|
||||||
limit, window_seconds = RATE_LIMITS[scope]
|
|
||||||
now_value = time.time()
|
|
||||||
cutoff = now_value - window_seconds
|
|
||||||
subject_hash = current_client_hash()
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("DELETE FROM rate_limit_events WHERE created_at < ?", (cutoff - 60,))
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT created_at
|
|
||||||
FROM rate_limit_events
|
|
||||||
WHERE scope = ?
|
|
||||||
AND subject_hash = ?
|
|
||||||
AND created_at >= ?
|
|
||||||
ORDER BY created_at ASC
|
|
||||||
""",
|
|
||||||
(scope, subject_hash, cutoff),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
if len(rows) >= limit:
|
|
||||||
oldest = rows[0]["created_at"]
|
|
||||||
return max(1, int(window_seconds - (now_value - oldest)) + 1)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO rate_limit_events (
|
|
||||||
scope,
|
|
||||||
subject_hash,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(scope, subject_hash, now_value),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def rate_limited(scope: str):
|
|
||||||
retry_after = check_rate_limit(scope)
|
|
||||||
if retry_after is None:
|
|
||||||
return None
|
|
||||||
return (
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"error": "Too many requests. Please wait and try again.",
|
|
||||||
"code": "rate_limited",
|
|
||||||
"retry_after": retry_after,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
429,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def user_payload(row, settings_row=None) -> dict:
|
|
||||||
status = row["status"]
|
|
||||||
restricted_until = parse_iso(row["restricted_until"])
|
|
||||||
if status == "restricted" and restricted_until and restricted_until <= utc_now():
|
|
||||||
status = "active"
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"role": row["role"],
|
|
||||||
"status": status,
|
|
||||||
"restricted_until": row["restricted_until"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def current_user() -> Optional[dict]:
|
|
||||||
user_id = session.get("user_id")
|
|
||||||
if not user_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
session.clear()
|
|
||||||
return None
|
|
||||||
|
|
||||||
user = user_payload(row)
|
|
||||||
|
|
||||||
if user["status"] == "restricted":
|
|
||||||
expires = parse_iso(user["restricted_until"])
|
|
||||||
if expires and expires <= utc_now():
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
status = 'active',
|
|
||||||
restricted_until = NULL,
|
|
||||||
restriction_reason = NULL,
|
|
||||||
is_active = 1
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
)
|
|
||||||
user["status"] = "active"
|
|
||||||
user["restricted_until"] = None
|
|
||||||
|
|
||||||
if user["status"] in {"disabled", "blacklisted"}:
|
|
||||||
session.clear()
|
|
||||||
return None
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def login_user(row) -> dict:
|
|
||||||
session.clear()
|
|
||||||
session.permanent = True
|
|
||||||
session["user_id"] = row["id"]
|
|
||||||
session["csrf_token"] = secrets.token_urlsafe(32)
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"role": row["role"],
|
|
||||||
"status": row["status"],
|
|
||||||
"restricted_until": row.get("restricted_until") if isinstance(row, dict) else row["restricted_until"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def require_user():
|
|
||||||
user = current_user()
|
|
||||||
if not user:
|
|
||||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_writable_user():
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
if user["status"] == "restricted":
|
|
||||||
return None, (
|
|
||||||
jsonify(
|
|
||||||
{
|
|
||||||
"error": "Your account is temporarily restricted.",
|
|
||||||
"code": "restricted",
|
|
||||||
"restricted_until": user["restricted_until"],
|
|
||||||
}
|
|
||||||
),
|
|
||||||
403,
|
|
||||||
)
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_moderator():
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
if user["role"] not in {"moderator", "admin"}:
|
|
||||||
return None, (jsonify({"error": "Moderator access required.", "code": "forbidden"}), 403)
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_admin():
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return None, error
|
|
||||||
if user["role"] != "admin":
|
|
||||||
return None, (jsonify({"error": "Administrator access required.", "code": "forbidden"}), 403)
|
|
||||||
return user, None
|
|
||||||
|
|
||||||
|
|
||||||
def private_access_key_valid(candidate: object) -> bool:
|
|
||||||
configured = (PRIVATE_ACCESS_KEY or "").strip()
|
|
||||||
if not configured:
|
|
||||||
return True
|
|
||||||
if not isinstance(candidate, str):
|
|
||||||
return False
|
|
||||||
return candidate.strip() == configured
|
|
||||||
|
|
||||||
|
|
||||||
def register_account(data: dict):
|
|
||||||
access_key = data.get("access_key", "")
|
|
||||||
if not private_access_key_valid(access_key):
|
|
||||||
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
|
||||||
|
|
||||||
username = normalize_username(data.get("username"))
|
|
||||||
password = data.get("password", "")
|
|
||||||
|
|
||||||
username_error = validate_username(username)
|
|
||||||
password_error = validate_password(password, username)
|
|
||||||
|
|
||||||
if username_error:
|
|
||||||
return None, (jsonify({"error": username_error}), 400)
|
|
||||||
if password_error:
|
|
||||||
return None, (jsonify({"error": password_error}), 400)
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO users (
|
|
||||||
username,
|
|
||||||
password_hash,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
is_admin,
|
|
||||||
is_active,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, 'user', 'active', 0, 1, ?)
|
|
||||||
""",
|
|
||||||
(username, generate_password_hash(password), now_iso()),
|
|
||||||
)
|
|
||||||
except Exception as error:
|
|
||||||
if "UNIQUE" in str(error).upper():
|
|
||||||
return None, (jsonify({"error": "Unable to create account with those details."}), 409)
|
|
||||||
raise
|
|
||||||
|
|
||||||
user = {
|
|
||||||
"id": cursor.lastrowid,
|
|
||||||
"username": username,
|
|
||||||
"role": "user",
|
|
||||||
"status": "active",
|
|
||||||
"restricted_until": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_profiles (
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
|
||||||
""",
|
|
||||||
(user["id"], username, now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_settings (
|
|
||||||
user_id,
|
|
||||||
accent_color,
|
|
||||||
name_color,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
|
||||||
""",
|
|
||||||
(user["id"], now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
authenticated = login_user(user)
|
|
||||||
audit("account_registered", user["id"], "user", str(user["id"]))
|
|
||||||
return authenticated, None
|
|
||||||
|
|
||||||
|
|
||||||
def authenticate_account(data: dict):
|
|
||||||
access_key = data.get("access_key", "")
|
|
||||||
if not private_access_key_valid(access_key):
|
|
||||||
return None, (jsonify({"error": "Invalid private access key."}), 403)
|
|
||||||
|
|
||||||
username = normalize_username(data.get("username"))
|
|
||||||
password = data.get("password", "")
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
password_hash,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until
|
|
||||||
FROM users
|
|
||||||
WHERE username = ?
|
|
||||||
""",
|
|
||||||
(username,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row or not isinstance(password, str) or not check_password_hash(row["password_hash"], password):
|
|
||||||
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
|
||||||
|
|
||||||
if row["status"] in {"disabled", "blacklisted"}:
|
|
||||||
return None, (jsonify({"error": "Invalid username or password."}), 401)
|
|
||||||
|
|
||||||
db_write("UPDATE users SET last_login_at = ? WHERE id = ?", (now_iso(), row["id"]))
|
|
||||||
authenticated = login_user(row)
|
|
||||||
audit("account_logged_in", row["id"], "user", str(row["id"]))
|
|
||||||
return authenticated, None
|
|
||||||
464
calls.py
464
calls.py
@ -1,464 +0,0 @@
|
|||||||
import json
|
|
||||||
import time
|
|
||||||
|
|
||||||
from db import db_write, get_db
|
|
||||||
from config import utc_now, now_iso, GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
|
||||||
|
|
||||||
|
|
||||||
def create_call_session(dm_id: int, started_by_user_id: int) -> int:
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO call_sessions (
|
|
||||||
direct_conversation_id,
|
|
||||||
started_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
) VALUES (?, ?, 'active', ?, NULL)
|
|
||||||
""",
|
|
||||||
(dm_id, started_by_user_id, now_iso()),
|
|
||||||
)
|
|
||||||
return cursor.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
def append_call_signal(call_id: int, from_user_id: int, signal_type: str, payload: dict) -> int:
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO call_signals (
|
|
||||||
call_session_id,
|
|
||||||
from_user_id,
|
|
||||||
signal_type,
|
|
||||||
payload_json,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
call_id,
|
|
||||||
from_user_id,
|
|
||||||
signal_type,
|
|
||||||
json.dumps(payload or {}),
|
|
||||||
now_iso(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return cursor.lastrowid
|
|
||||||
|
|
||||||
|
|
||||||
def list_call_signals(call_id: int, after_id: int = 0) -> list[dict]:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
cs.id,
|
|
||||||
cs.call_session_id,
|
|
||||||
cs.from_user_id,
|
|
||||||
u.username,
|
|
||||||
cs.signal_type,
|
|
||||||
cs.payload_json,
|
|
||||||
cs.created_at
|
|
||||||
FROM call_signals AS cs
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = cs.from_user_id
|
|
||||||
WHERE cs.call_session_id = ?
|
|
||||||
AND cs.id > ?
|
|
||||||
ORDER BY cs.id ASC
|
|
||||||
LIMIT 200
|
|
||||||
""",
|
|
||||||
(call_id, after_id),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
try:
|
|
||||||
payload = json.loads(row["payload_json"] or "{}")
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
payload = {}
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"call_session_id": row["call_session_id"],
|
|
||||||
"from_user_id": row["from_user_id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"signal_type": row["signal_type"],
|
|
||||||
"payload": payload,
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def end_call(call_id: int) -> None:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE call_sessions
|
|
||||||
SET
|
|
||||||
status = 'ended',
|
|
||||||
ended_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), call_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_call_with_dm(call_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
cs.id,
|
|
||||||
cs.direct_conversation_id,
|
|
||||||
cs.started_by_user_id,
|
|
||||||
cs.status,
|
|
||||||
cs.created_at,
|
|
||||||
cs.ended_at
|
|
||||||
FROM call_sessions AS cs
|
|
||||||
WHERE cs.id = ?
|
|
||||||
""",
|
|
||||||
(call_id,),
|
|
||||||
).fetchone()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def call_payload(call_id: int):
|
|
||||||
row = get_call_with_dm(call_id)
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"direct_conversation_id": row["direct_conversation_id"],
|
|
||||||
"started_by_user_id": row["started_by_user_id"],
|
|
||||||
"status": row["status"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"ended_at": row["ended_at"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_room_helper_call_id(room) -> str:
|
|
||||||
return f"room-{room['slug']}"
|
|
||||||
|
|
||||||
|
|
||||||
def build_channel_helper_call_id(channel) -> str:
|
|
||||||
return f"channel-{channel['team_slug']}--{channel['slug']}"
|
|
||||||
|
|
||||||
|
|
||||||
def get_or_create_group_call_session(
|
|
||||||
scope_kind: str,
|
|
||||||
scope_id: int,
|
|
||||||
scope_slug: str,
|
|
||||||
helper_call_id: str,
|
|
||||||
created_by_user_id: int,
|
|
||||||
):
|
|
||||||
with get_db() as db:
|
|
||||||
existing = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
FROM group_call_sessions
|
|
||||||
WHERE helper_call_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(helper_call_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing and existing["status"] == "active":
|
|
||||||
return existing
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
existing = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
FROM group_call_sessions
|
|
||||||
WHERE helper_call_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(helper_call_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing and existing["status"] == "active":
|
|
||||||
db.commit()
|
|
||||||
return existing
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_sessions
|
|
||||||
SET
|
|
||||||
status = 'active',
|
|
||||||
ended_at = NULL,
|
|
||||||
created_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), existing["id"]),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
refreshed = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
FROM group_call_sessions
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(existing["id"],),
|
|
||||||
).fetchone()
|
|
||||||
return refreshed
|
|
||||||
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_call_sessions (
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 'active', ?, NULL)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
now_iso(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
session_id = cursor.lastrowid
|
|
||||||
db.commit()
|
|
||||||
created = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
FROM group_call_sessions
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchone()
|
|
||||||
return created
|
|
||||||
|
|
||||||
|
|
||||||
def group_call_session_payload(row):
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"scope_kind": row["scope_kind"],
|
|
||||||
"scope_id": row["scope_id"],
|
|
||||||
"scope_slug": row["scope_slug"],
|
|
||||||
"helper_call_id": row["helper_call_id"],
|
|
||||||
"created_by_user_id": row["created_by_user_id"],
|
|
||||||
"status": row["status"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"ended_at": row["ended_at"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_group_call_session_by_id(session_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
scope_kind,
|
|
||||||
scope_id,
|
|
||||||
scope_slug,
|
|
||||||
helper_call_id,
|
|
||||||
created_by_user_id,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
ended_at
|
|
||||||
FROM group_call_sessions
|
|
||||||
WHERE id = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def cleanup_stale_group_call_participants(session_id: int):
|
|
||||||
cutoff = utc_now().timestamp() - GROUP_CALL_PARTICIPANT_STALE_SECONDS
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_participants
|
|
||||||
SET
|
|
||||||
left_at = COALESCE(left_at, ?)
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND left_at IS NULL
|
|
||||||
AND (
|
|
||||||
updated_at IS NULL
|
|
||||||
OR strftime('%s', updated_at) < ?
|
|
||||||
)
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id, int(cutoff)),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upsert_group_call_participant(session_id: int, user_id: int):
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_call_participants (
|
|
||||||
group_call_session_id,
|
|
||||||
user_id,
|
|
||||||
joined_at,
|
|
||||||
updated_at,
|
|
||||||
left_at
|
|
||||||
) VALUES (?, ?, ?, ?, NULL)
|
|
||||||
ON CONFLICT(group_call_session_id, user_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
updated_at = excluded.updated_at,
|
|
||||||
left_at = NULL
|
|
||||||
""",
|
|
||||||
(session_id, user_id, now_iso(), now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def heartbeat_group_call_participant(session_id: int, user_id: int):
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_participants
|
|
||||||
SET
|
|
||||||
updated_at = ?,
|
|
||||||
left_at = NULL
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mark_group_call_participant_left(session_id: int, user_id: int):
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_participants
|
|
||||||
SET
|
|
||||||
updated_at = ?,
|
|
||||||
left_at = ?
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
AND left_at IS NULL
|
|
||||||
""",
|
|
||||||
(now_iso(), now_iso(), session_id, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def list_group_call_participants(session_id: int) -> list[dict]:
|
|
||||||
cleanup_stale_group_call_participants(session_id)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
gcp.group_call_session_id,
|
|
||||||
gcp.user_id,
|
|
||||||
gcp.joined_at,
|
|
||||||
gcp.updated_at,
|
|
||||||
gcp.left_at,
|
|
||||||
u.username,
|
|
||||||
u.role,
|
|
||||||
up.display_name,
|
|
||||||
us.name_color
|
|
||||||
FROM group_call_participants AS gcp
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = gcp.user_id
|
|
||||||
LEFT JOIN user_profiles AS up
|
|
||||||
ON up.user_id = u.id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = u.id
|
|
||||||
WHERE gcp.group_call_session_id = ?
|
|
||||||
AND gcp.left_at IS NULL
|
|
||||||
ORDER BY gcp.joined_at ASC, u.username COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"user_id": row["user_id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"display_name": row["display_name"] or row["username"],
|
|
||||||
"role": row["role"],
|
|
||||||
"name_color": row["name_color"],
|
|
||||||
"joined_at": row["joined_at"],
|
|
||||||
"updated_at": row["updated_at"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def active_group_call_participant_count(session_id: int) -> int:
|
|
||||||
cleanup_stale_group_call_participants(session_id)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM group_call_participants
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND left_at IS NULL
|
|
||||||
""",
|
|
||||||
(session_id,),
|
|
||||||
).fetchone()
|
|
||||||
return int(row["count"]) if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
def maybe_end_group_call_if_empty(session_id: int):
|
|
||||||
if active_group_call_participant_count(session_id) > 0:
|
|
||||||
return
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_sessions
|
|
||||||
SET
|
|
||||||
status = 'ended',
|
|
||||||
ended_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
AND status != 'ended'
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_invites
|
|
||||||
SET
|
|
||||||
status = 'dismissed',
|
|
||||||
responded_at = ?
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND status = 'pending'
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id),
|
|
||||||
)
|
|
||||||
231
config.py
231
config.py
@ -1,231 +0,0 @@
|
|||||||
import os
|
|
||||||
import secrets
|
|
||||||
from datetime import timedelta, timezone, datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
APP_ROOT = Path(__file__).resolve().parent
|
|
||||||
|
|
||||||
DB_PATH = os.environ.get("CHAT_DB_PATH", str(APP_ROOT / "chat.db"))
|
|
||||||
UPLOAD_DIR = Path(os.environ.get("CHAT_UPLOAD_DIR", str(APP_ROOT / "uploads")))
|
|
||||||
|
|
||||||
SECRET_KEY_PATH = os.environ.get(
|
|
||||||
"CHAT_SECRET_KEY_PATH",
|
|
||||||
str(Path(DB_PATH).resolve().parent / "flask_secret.key"),
|
|
||||||
)
|
|
||||||
CLIENT_HASH_KEY_PATH = os.environ.get(
|
|
||||||
"CHAT_CLIENT_HASH_KEY_PATH",
|
|
||||||
str(Path(DB_PATH).resolve().parent / "client_hash.key"),
|
|
||||||
)
|
|
||||||
|
|
||||||
MAX_ATTACHMENT_BYTES = 100 * 1024 * 1024
|
|
||||||
MAX_REQUEST_BYTES = max(110 * 1024 * 1024, MAX_ATTACHMENT_BYTES + 10 * 1024 * 1024)
|
|
||||||
|
|
||||||
MAX_MESSAGE_LENGTH = 1000
|
|
||||||
MAX_USERNAME_LENGTH = 30
|
|
||||||
MIN_USERNAME_LENGTH = 3
|
|
||||||
MIN_PASSWORD_LENGTH = 7
|
|
||||||
MAX_PASSWORD_LENGTH = 256
|
|
||||||
MAX_HISTORY_MESSAGES = 100
|
|
||||||
MAX_INCREMENTAL_MESSAGES = 100
|
|
||||||
MAX_FEED_MESSAGES = 50
|
|
||||||
MAX_THREAD_REPLIES = 100
|
|
||||||
MAX_ATTACHMENT_NAME_LENGTH = 150
|
|
||||||
ATTACHMENT_ORPHAN_TTL_HOURS = 24
|
|
||||||
ATTACHMENT_CLEANUP_INTERVAL_SECONDS = 60 * 60
|
|
||||||
INVITE_TTL_HOURS = 72
|
|
||||||
TEAM_INVITE_TTL_HOURS = 72
|
|
||||||
TYPING_TTL_SECONDS = 7
|
|
||||||
PRESENCE_TTL_SECONDS = 40
|
|
||||||
MAX_PROFILE_BIO_LENGTH = 5000
|
|
||||||
MAX_DISPLAY_NAME_LENGTH = 80
|
|
||||||
MAX_LOCATION_LENGTH = 120
|
|
||||||
MAX_WEBSITE_LENGTH = 300
|
|
||||||
MAX_CUSTOM_CSS_LENGTH = 20000
|
|
||||||
MAX_PROFILE_BADGE_LENGTH = 48
|
|
||||||
MAX_OWNED_TEAMS_PER_USER = 3
|
|
||||||
MAX_MESSAGE_EDIT_WINDOW_SECONDS = 15 * 60
|
|
||||||
MAX_SEARCH_QUERY_LENGTH = 120
|
|
||||||
MAX_SEARCH_RESULTS = 100
|
|
||||||
READ_RECEIPT_BATCH_LIMIT = 200
|
|
||||||
GROUP_CALL_PARTICIPANT_STALE_SECONDS = 45
|
|
||||||
|
|
||||||
PRIVATE_ACCESS_KEY = os.environ.get("PRIVATE_ACCESS_KEY", "").strip()
|
|
||||||
|
|
||||||
CALLS_BASE = os.environ.get("CALLS_BASE", "https://calls.example.com").rstrip("/")
|
|
||||||
CALL_HELPER_SHARED_SECRET = os.environ.get("CALL_HELPER_SHARED_SECRET", "").strip()
|
|
||||||
CALL_TOKEN_TTL_SECONDS = int(os.environ.get("CALL_TOKEN_TTL_SECONDS", "60"))
|
|
||||||
|
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS = {
|
|
||||||
"txt",
|
|
||||||
"pdf",
|
|
||||||
"png",
|
|
||||||
"jpg",
|
|
||||||
"jpeg",
|
|
||||||
"gif",
|
|
||||||
"webp",
|
|
||||||
"mp3",
|
|
||||||
"wav",
|
|
||||||
"ogg",
|
|
||||||
"m4a",
|
|
||||||
"aac",
|
|
||||||
"flac",
|
|
||||||
"mp4",
|
|
||||||
"webm",
|
|
||||||
"mov",
|
|
||||||
"m4v",
|
|
||||||
"ogv",
|
|
||||||
}
|
|
||||||
|
|
||||||
ALLOWED_ATTACHMENT_MIME_TYPES = {
|
|
||||||
"text/plain",
|
|
||||||
"application/pdf",
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"audio/mpeg",
|
|
||||||
"audio/mp3",
|
|
||||||
"audio/wav",
|
|
||||||
"audio/x-wav",
|
|
||||||
"audio/ogg",
|
|
||||||
"audio/mp4",
|
|
||||||
"audio/aac",
|
|
||||||
"audio/flac",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
"video/quicktime",
|
|
||||||
"video/ogg",
|
|
||||||
}
|
|
||||||
|
|
||||||
ROLES = {"user", "moderator", "admin"}
|
|
||||||
STATUSES = {"active", "disabled", "restricted", "blacklisted"}
|
|
||||||
ROOM_VISIBILITIES = {"public", "private"}
|
|
||||||
|
|
||||||
RATE_LIMITS = {
|
|
||||||
"login": (8, 10 * 60),
|
|
||||||
"register": (5, 60 * 60),
|
|
||||||
"send": (30, 60),
|
|
||||||
"admin": (60, 60),
|
|
||||||
"attachment": (20, 60),
|
|
||||||
"typing": (60, 60),
|
|
||||||
"reaction": (120, 60),
|
|
||||||
"profile": (30, 60),
|
|
||||||
"settings": (30, 60),
|
|
||||||
}
|
|
||||||
|
|
||||||
PLACEHOLDER_SECRETS = {
|
|
||||||
"",
|
|
||||||
"change-this-secret-key",
|
|
||||||
"replace-this-with-a-long-random-secret",
|
|
||||||
"replace-me",
|
|
||||||
"secret",
|
|
||||||
"changeme",
|
|
||||||
}
|
|
||||||
|
|
||||||
RUNTIME_STATE = {
|
|
||||||
"last_attachment_cleanup": 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def utc_now() -> datetime:
|
|
||||||
return datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
|
|
||||||
def now_iso() -> str:
|
|
||||||
return utc_now().isoformat()
|
|
||||||
|
|
||||||
|
|
||||||
def parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
||||||
if not value:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(value)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
if not parsed.tzinfo:
|
|
||||||
return parsed.replace(tzinfo=timezone.utc)
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def load_or_create_key(path_value: str, env_value: str, min_length: int = 32) -> str:
|
|
||||||
configured = os.environ.get(env_value, "").strip()
|
|
||||||
if configured and configured.lower() not in PLACEHOLDER_SECRETS and len(configured) >= min_length:
|
|
||||||
return configured
|
|
||||||
|
|
||||||
path = Path(path_value)
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if path.exists():
|
|
||||||
existing = path.read_text(encoding="utf-8").strip()
|
|
||||||
if len(existing) >= min_length:
|
|
||||||
return existing
|
|
||||||
|
|
||||||
generated = secrets.token_urlsafe(64)
|
|
||||||
try:
|
|
||||||
path.write_text(generated, encoding="utf-8")
|
|
||||||
os.chmod(path, 0o600)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
return generated
|
|
||||||
|
|
||||||
|
|
||||||
SECRET_KEY = load_or_create_key(SECRET_KEY_PATH, "FLASK_SECRET_KEY")
|
|
||||||
CLIENT_HASH_KEY = load_or_create_key(CLIENT_HASH_KEY_PATH, "CHAT_CLIENT_HASH_KEY")
|
|
||||||
|
|
||||||
TRUSTED_PROXY_HOPS = int(os.environ.get("TRUSTED_PROXY_HOPS", "0"))
|
|
||||||
SESSION_COOKIE_SECURE = (os.environ.get("SESSION_COOKIE_SECURE", "1") == "1")
|
|
||||||
PERMANENT_SESSION_LIFETIME = timedelta(days=14)
|
|
||||||
|
|
||||||
|
|
||||||
def get_asset_version() -> str:
|
|
||||||
try:
|
|
||||||
js_dir = APP_ROOT / "static" / "js"
|
|
||||||
values = [int((APP_ROOT / "static" / "style.css").stat().st_mtime)]
|
|
||||||
values.extend(int(path.stat().st_mtime) for path in js_dir.glob("*.js"))
|
|
||||||
return str(max(values))
|
|
||||||
except OSError:
|
|
||||||
return "1"
|
|
||||||
|
|
||||||
|
|
||||||
def flask_config() -> dict:
|
|
||||||
return {
|
|
||||||
"MAX_CONTENT_LENGTH": MAX_REQUEST_BYTES,
|
|
||||||
"SESSION_COOKIE_NAME": "chat_session",
|
|
||||||
"SESSION_COOKIE_HTTPONLY": True,
|
|
||||||
"SESSION_COOKIE_SAMESITE": "Lax",
|
|
||||||
"SESSION_COOKIE_SECURE": SESSION_COOKIE_SECURE,
|
|
||||||
"PERMANENT_SESSION_LIFETIME": PERMANENT_SESSION_LIFETIME,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
CONSTANTS = {
|
|
||||||
"MAX_MESSAGE_LENGTH": MAX_MESSAGE_LENGTH,
|
|
||||||
"MAX_HISTORY_MESSAGES": MAX_HISTORY_MESSAGES,
|
|
||||||
"MAX_INCREMENTAL_MESSAGES": MAX_INCREMENTAL_MESSAGES,
|
|
||||||
"MAX_FEED_MESSAGES": MAX_FEED_MESSAGES,
|
|
||||||
"MAX_THREAD_REPLIES": MAX_THREAD_REPLIES,
|
|
||||||
"MAX_ATTACHMENT_BYTES": MAX_ATTACHMENT_BYTES,
|
|
||||||
"MAX_ATTACHMENT_NAME_LENGTH": MAX_ATTACHMENT_NAME_LENGTH,
|
|
||||||
"ALLOWED_ATTACHMENT_EXTENSIONS": ALLOWED_ATTACHMENT_EXTENSIONS,
|
|
||||||
"ALLOWED_ATTACHMENT_MIME_TYPES": ALLOWED_ATTACHMENT_MIME_TYPES,
|
|
||||||
"INVITE_TTL_HOURS": INVITE_TTL_HOURS,
|
|
||||||
"TEAM_INVITE_TTL_HOURS": TEAM_INVITE_TTL_HOURS,
|
|
||||||
"TYPING_TTL_SECONDS": TYPING_TTL_SECONDS,
|
|
||||||
"PRESENCE_TTL_SECONDS": PRESENCE_TTL_SECONDS,
|
|
||||||
"MAX_SEARCH_QUERY_LENGTH": MAX_SEARCH_QUERY_LENGTH,
|
|
||||||
"MAX_SEARCH_RESULTS": MAX_SEARCH_RESULTS,
|
|
||||||
"READ_RECEIPT_BATCH_LIMIT": READ_RECEIPT_BATCH_LIMIT,
|
|
||||||
"MAX_MESSAGE_EDIT_WINDOW_SECONDS": MAX_MESSAGE_EDIT_WINDOW_SECONDS,
|
|
||||||
"MAX_DISPLAY_NAME_LENGTH": MAX_DISPLAY_NAME_LENGTH,
|
|
||||||
"MAX_PROFILE_BIO_LENGTH": MAX_PROFILE_BIO_LENGTH,
|
|
||||||
"MAX_LOCATION_LENGTH": MAX_LOCATION_LENGTH,
|
|
||||||
"MAX_WEBSITE_LENGTH": MAX_WEBSITE_LENGTH,
|
|
||||||
"MAX_CUSTOM_CSS_LENGTH": MAX_CUSTOM_CSS_LENGTH,
|
|
||||||
"MAX_PROFILE_BADGE_LENGTH": MAX_PROFILE_BADGE_LENGTH,
|
|
||||||
"MAX_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER,
|
|
||||||
"ROOM_VISIBILITIES": ROOM_VISIBILITIES,
|
|
||||||
"ROLES": ROLES,
|
|
||||||
"STATUSES": STATUSES,
|
|
||||||
"GROUP_CALL_PARTICIPANT_STALE_SECONDS": GROUP_CALL_PARTICIPANT_STALE_SECONDS,
|
|
||||||
}
|
|
||||||
948
db.py
948
db.py
@ -1,948 +0,0 @@
|
|||||||
import os
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from werkzeug.security import generate_password_hash
|
|
||||||
|
|
||||||
from config import (
|
|
||||||
DB_PATH,
|
|
||||||
UPLOAD_DIR,
|
|
||||||
ROLES,
|
|
||||||
STATUSES,
|
|
||||||
now_iso,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
|
||||||
db_dir = os.path.dirname(DB_PATH)
|
|
||||||
if db_dir:
|
|
||||||
os.makedirs(db_dir, exist_ok=True)
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH, timeout=8, isolation_level=None, check_same_thread=False)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
conn.execute("PRAGMA foreign_keys = ON")
|
|
||||||
conn.execute("PRAGMA journal_mode = WAL")
|
|
||||||
conn.execute("PRAGMA busy_timeout = 8000")
|
|
||||||
conn.execute("PRAGMA synchronous = NORMAL")
|
|
||||||
return conn
|
|
||||||
|
|
||||||
|
|
||||||
def db_write(query: str, params: tuple = ()) -> sqlite3.Cursor:
|
|
||||||
last_error = None
|
|
||||||
for attempt in range(5):
|
|
||||||
try:
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
cursor = db.execute(query, params)
|
|
||||||
db.commit()
|
|
||||||
return cursor
|
|
||||||
except sqlite3.OperationalError as error:
|
|
||||||
last_error = error
|
|
||||||
if "locked" not in str(error).lower() and "busy" not in str(error).lower():
|
|
||||||
raise
|
|
||||||
if attempt == 4:
|
|
||||||
raise
|
|
||||||
time.sleep(0.05 * (2 ** attempt))
|
|
||||||
raise last_error
|
|
||||||
|
|
||||||
|
|
||||||
def column_exists(db: sqlite3.Connection, table: str, column: str) -> bool:
|
|
||||||
rows = db.execute(f"PRAGMA table_info({table})").fetchall()
|
|
||||||
return any(row["name"] == column for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
def init_db() -> None:
|
|
||||||
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
username TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
role TEXT NOT NULL DEFAULT 'user',
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
restricted_until TEXT,
|
|
||||||
restriction_reason TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
last_login_at TEXT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rooms (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
created_by_user_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS room_members (
|
|
||||||
room_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (room_id, user_id),
|
|
||||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS attachments (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uploader_user_id INTEGER NOT NULL,
|
|
||||||
stored_name TEXT UNIQUE NOT NULL,
|
|
||||||
original_name TEXT NOT NULL,
|
|
||||||
mime_type TEXT NOT NULL,
|
|
||||||
size_bytes INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (uploader_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS teams (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
slug TEXT COLLATE NOCASE UNIQUE NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_by_user_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS team_members (
|
|
||||||
team_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (team_id, user_id),
|
|
||||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS team_invites (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
team_id INTEGER NOT NULL,
|
|
||||||
token_hash TEXT UNIQUE NOT NULL,
|
|
||||||
created_by_user_id INTEGER NOT NULL,
|
|
||||||
membership_role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by_user_id INTEGER,
|
|
||||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS channels (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
team_id INTEGER NOT NULL,
|
|
||||||
legacy_room_id INTEGER UNIQUE,
|
|
||||||
slug TEXT COLLATE NOCASE NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
description TEXT,
|
|
||||||
created_by_user_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
UNIQUE(team_id, slug),
|
|
||||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (legacy_room_id) REFERENCES rooms(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS direct_conversations (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
member_pair_key TEXT UNIQUE NOT NULL,
|
|
||||||
created_at TEXT NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS direct_conversation_members (
|
|
||||||
direct_conversation_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
added_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (direct_conversation_id, user_id),
|
|
||||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id INTEGER,
|
|
||||||
channel_id INTEGER,
|
|
||||||
direct_conversation_id INTEGER,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
username TEXT NOT NULL,
|
|
||||||
text TEXT NOT NULL,
|
|
||||||
attachment_id INTEGER,
|
|
||||||
parent_message_id INTEGER,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS room_invites (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id INTEGER NOT NULL,
|
|
||||||
token_hash TEXT UNIQUE NOT NULL,
|
|
||||||
created_by_user_id INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
used_at TEXT,
|
|
||||||
used_by_user_id INTEGER,
|
|
||||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (used_by_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS typing_events (
|
|
||||||
room_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
updated_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY (room_id, user_id),
|
|
||||||
FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS channel_typing_events (
|
|
||||||
channel_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
updated_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY (channel_id, user_id),
|
|
||||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS dm_typing_events (
|
|
||||||
direct_conversation_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
updated_at REAL NOT NULL,
|
|
||||||
PRIMARY KEY (direct_conversation_id, user_id),
|
|
||||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS blacklist_entries (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
value_hash TEXT NOT NULL,
|
|
||||||
reason TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT,
|
|
||||||
created_by_user_id INTEGER,
|
|
||||||
UNIQUE(kind, value_hash),
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS rate_limit_events (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
scope TEXT NOT NULL,
|
|
||||||
subject_hash TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS audit_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
actor_user_id INTEGER,
|
|
||||||
action TEXT NOT NULL,
|
|
||||||
target_type TEXT,
|
|
||||||
target_id TEXT,
|
|
||||||
details TEXT,
|
|
||||||
client_hash TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS pages (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
team_id INTEGER NOT NULL,
|
|
||||||
channel_id INTEGER,
|
|
||||||
slug TEXT COLLATE NOCASE NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
body TEXT NOT NULL DEFAULT '',
|
|
||||||
visibility TEXT NOT NULL DEFAULT 'team',
|
|
||||||
page_type TEXT NOT NULL DEFAULT 'document',
|
|
||||||
created_by_user_id INTEGER NOT NULL,
|
|
||||||
updated_by_user_id INTEGER NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
is_active INTEGER NOT NULL DEFAULT 1,
|
|
||||||
UNIQUE(team_id, slug),
|
|
||||||
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE SET NULL,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE RESTRICT,
|
|
||||||
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE RESTRICT
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS user_profiles (
|
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
display_name TEXT,
|
|
||||||
badge_text TEXT,
|
|
||||||
bio TEXT NOT NULL DEFAULT '',
|
|
||||||
bio_mode TEXT NOT NULL DEFAULT 'txt',
|
|
||||||
website TEXT,
|
|
||||||
location TEXT,
|
|
||||||
status_message TEXT NOT NULL DEFAULT '',
|
|
||||||
banner_attachment_id INTEGER,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (banner_attachment_id) REFERENCES attachments(id) ON DELETE SET NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS user_settings (
|
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
accent_color TEXT,
|
|
||||||
name_color TEXT,
|
|
||||||
custom_css TEXT NOT NULL DEFAULT '',
|
|
||||||
theme_mode TEXT NOT NULL DEFAULT 'dark',
|
|
||||||
profile_background_color TEXT,
|
|
||||||
profile_banner_color TEXT,
|
|
||||||
profile_visibility TEXT NOT NULL DEFAULT 'public',
|
|
||||||
last_active_visibility TEXT NOT NULL DEFAULT 'everyone',
|
|
||||||
presence_mode TEXT NOT NULL DEFAULT 'online',
|
|
||||||
dm_layout_mode TEXT NOT NULL DEFAULT 'cards',
|
|
||||||
show_presence_badge INTEGER NOT NULL DEFAULT 1,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_reactions (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
emoji TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (message_id, user_id, emoji),
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_edits (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
edited_by_user_id INTEGER NOT NULL,
|
|
||||||
previous_text TEXT NOT NULL,
|
|
||||||
new_text TEXT NOT NULL,
|
|
||||||
previous_attachments_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
new_attachments_json TEXT NOT NULL DEFAULT '[]',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (edited_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_receipts (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
seen_at TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (message_id, user_id),
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_attachments (
|
|
||||||
message_id INTEGER NOT NULL,
|
|
||||||
attachment_id INTEGER NOT NULL,
|
|
||||||
position INTEGER NOT NULL DEFAULT 0,
|
|
||||||
PRIMARY KEY (message_id, attachment_id),
|
|
||||||
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (attachment_id) REFERENCES attachments(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS presence_events (
|
|
||||||
user_id INTEGER PRIMARY KEY,
|
|
||||||
updated_at REAL NOT NULL,
|
|
||||||
view_name TEXT,
|
|
||||||
room_id INTEGER,
|
|
||||||
channel_id INTEGER,
|
|
||||||
direct_conversation_id INTEGER,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS call_sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
direct_conversation_id INTEGER NOT NULL,
|
|
||||||
started_by_user_id INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT,
|
|
||||||
FOREIGN KEY (direct_conversation_id) REFERENCES direct_conversations(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (started_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS call_signals (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
call_session_id INTEGER NOT NULL,
|
|
||||||
from_user_id INTEGER NOT NULL,
|
|
||||||
signal_type TEXT NOT NULL,
|
|
||||||
payload_json TEXT NOT NULL DEFAULT '{}',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
FOREIGN KEY (call_session_id) REFERENCES call_sessions(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (from_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS group_call_sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
scope_kind TEXT NOT NULL,
|
|
||||||
scope_id INTEGER NOT NULL,
|
|
||||||
scope_slug TEXT NOT NULL,
|
|
||||||
helper_call_id TEXT NOT NULL UNIQUE,
|
|
||||||
created_by_user_id INTEGER NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
ended_at TEXT,
|
|
||||||
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS group_call_invites (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
group_call_session_id INTEGER NOT NULL,
|
|
||||||
inviter_user_id INTEGER NOT NULL,
|
|
||||||
invitee_user_id INTEGER NOT NULL,
|
|
||||||
helper_token TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'pending',
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
expires_at TEXT NOT NULL,
|
|
||||||
responded_at TEXT,
|
|
||||||
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (inviter_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (invitee_user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS group_call_participants (
|
|
||||||
group_call_session_id INTEGER NOT NULL,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
joined_at TEXT NOT NULL,
|
|
||||||
updated_at TEXT NOT NULL,
|
|
||||||
left_at TEXT,
|
|
||||||
PRIMARY KEY (group_call_session_id, user_id),
|
|
||||||
FOREIGN KEY (group_call_session_id) REFERENCES group_call_sessions(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
if not column_exists(db, "messages", "room_id"):
|
|
||||||
db.execute("ALTER TABLE messages ADD COLUMN room_id INTEGER")
|
|
||||||
if not column_exists(db, "messages", "attachment_id"):
|
|
||||||
db.execute("ALTER TABLE messages ADD COLUMN attachment_id INTEGER")
|
|
||||||
if not column_exists(db, "messages", "parent_message_id"):
|
|
||||||
db.execute("ALTER TABLE messages ADD COLUMN parent_message_id INTEGER")
|
|
||||||
if not column_exists(db, "messages", "channel_id"):
|
|
||||||
db.execute("ALTER TABLE messages ADD COLUMN channel_id INTEGER")
|
|
||||||
if not column_exists(db, "messages", "direct_conversation_id"):
|
|
||||||
db.execute("ALTER TABLE messages ADD COLUMN direct_conversation_id INTEGER")
|
|
||||||
|
|
||||||
if not column_exists(db, "user_profiles", "badge_text"):
|
|
||||||
db.execute("ALTER TABLE user_profiles ADD COLUMN badge_text TEXT")
|
|
||||||
if not column_exists(db, "user_profiles", "status_message"):
|
|
||||||
db.execute("ALTER TABLE user_profiles ADD COLUMN status_message TEXT NOT NULL DEFAULT ''")
|
|
||||||
if not column_exists(db, "user_profiles", "banner_attachment_id"):
|
|
||||||
db.execute("ALTER TABLE user_profiles ADD COLUMN banner_attachment_id INTEGER")
|
|
||||||
|
|
||||||
if not column_exists(db, "user_settings", "name_color"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT")
|
|
||||||
if not column_exists(db, "user_settings", "profile_background_color"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_background_color TEXT")
|
|
||||||
if not column_exists(db, "user_settings", "profile_banner_color"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_banner_color TEXT")
|
|
||||||
if not column_exists(db, "user_settings", "profile_visibility"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN profile_visibility TEXT NOT NULL DEFAULT 'public'")
|
|
||||||
if not column_exists(db, "user_settings", "last_active_visibility"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN last_active_visibility TEXT NOT NULL DEFAULT 'everyone'")
|
|
||||||
if not column_exists(db, "user_settings", "presence_mode"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN presence_mode TEXT NOT NULL DEFAULT 'online'")
|
|
||||||
if not column_exists(db, "user_settings", "dm_layout_mode"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN dm_layout_mode TEXT NOT NULL DEFAULT 'cards'")
|
|
||||||
if not column_exists(db, "user_settings", "show_presence_badge"):
|
|
||||||
db.execute("ALTER TABLE user_settings ADD COLUMN show_presence_badge INTEGER NOT NULL DEFAULT 1")
|
|
||||||
|
|
||||||
if not column_exists(db, "presence_events", "direct_conversation_id"):
|
|
||||||
db.execute("ALTER TABLE presence_events ADD COLUMN direct_conversation_id INTEGER")
|
|
||||||
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_room_id_id ON messages(room_id, id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_channel_id_id ON messages(channel_id, id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_dm_id_id ON messages(direct_conversation_id, id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_parent_message_id_id ON messages(parent_message_id, id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_messages_attachment_id ON messages(attachment_id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_room_members_user ON room_members(user_id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_team_members_user_id ON team_members(user_id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_team_id_slug ON channels(team_id, slug)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channels_legacy_room_id ON channels(legacy_room_id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_pages_team_updated_at ON pages(team_id, updated_at DESC)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_channel_typing_events_updated_at ON channel_typing_events(channel_id, updated_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_dm_typing_events_updated_at ON dm_typing_events(direct_conversation_id, updated_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_invites_token_hash ON room_invites(token_hash)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_team_invites_token_hash ON team_invites(token_hash)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_typing_room_updated ON typing_events(room_id, updated_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_blacklist_lookup ON blacklist_entries(kind, value_hash)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_rate_limit_lookup ON rate_limit_events(scope, subject_hash, created_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_reactions_message ON message_reactions(message_id, emoji)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_receipts_user_seen ON message_receipts(user_id, seen_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_message_attachments_message_position ON message_attachments(message_id, position)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_updated_at ON presence_events(updated_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_presence_events_dm_id ON presence_events(direct_conversation_id, updated_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_direct_conversation_members_user ON direct_conversation_members(user_id, direct_conversation_id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_call_signals_lookup ON call_signals(call_session_id, id)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_user_settings_name_color ON user_settings(user_id, name_color)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_sessions_scope ON group_call_sessions(scope_kind, scope_id, status)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_invites_invitee_status ON group_call_invites(invitee_user_id, status, expires_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_active ON group_call_participants(group_call_session_id, left_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_user_active ON group_call_participants(user_id, left_at)")
|
|
||||||
db.execute("CREATE INDEX IF NOT EXISTS idx_group_call_participants_session_updated ON group_call_participants(group_call_session_id, updated_at)")
|
|
||||||
|
|
||||||
general = db.execute("SELECT id FROM rooms WHERE slug = 'general'").fetchone()
|
|
||||||
if not general:
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO rooms (
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
) VALUES (?, ?, ?, ?, ?, 1)
|
|
||||||
""",
|
|
||||||
("general", "general", "Default public chat room", "public", now_iso()),
|
|
||||||
)
|
|
||||||
general_id = cursor.lastrowid
|
|
||||||
else:
|
|
||||||
general_id = general["id"]
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"UPDATE messages SET room_id = ? WHERE room_id IS NULL AND direct_conversation_id IS NULL",
|
|
||||||
(general_id,),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET role = CASE
|
|
||||||
WHEN is_admin = 1 THEN 'admin'
|
|
||||||
ELSE 'user'
|
|
||||||
END
|
|
||||||
WHERE role IS NULL
|
|
||||||
OR role = ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET status = CASE
|
|
||||||
WHEN is_active = 1 THEN 'active'
|
|
||||||
ELSE 'disabled'
|
|
||||||
END
|
|
||||||
WHERE status IS NULL
|
|
||||||
OR status = ''
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
general_team = db.execute("SELECT id FROM teams WHERE slug = 'general'").fetchone()
|
|
||||||
if general_team:
|
|
||||||
general_team_id = general_team["id"]
|
|
||||||
else:
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO teams (
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
) VALUES (?, ?, ?, NULL, ?, 1)
|
|
||||||
""",
|
|
||||||
("general", "General", "Default team for existing chat rooms.", now_iso()),
|
|
||||||
)
|
|
||||||
general_team_id = cursor.lastrowid
|
|
||||||
|
|
||||||
legacy_rooms = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at
|
|
||||||
FROM rooms
|
|
||||||
WHERE is_active = 1
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
for room in legacy_rooms:
|
|
||||||
existing_channel = db.execute(
|
|
||||||
"SELECT id FROM channels WHERE legacy_room_id = ?",
|
|
||||||
(room["id"],),
|
|
||||||
).fetchone()
|
|
||||||
if existing_channel:
|
|
||||||
continue
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO channels (
|
|
||||||
team_id,
|
|
||||||
legacy_room_id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
general_team_id,
|
|
||||||
room["id"],
|
|
||||||
room["slug"],
|
|
||||||
room["name"],
|
|
||||||
room["description"],
|
|
||||||
room["created_by_user_id"],
|
|
||||||
room["created_at"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE messages
|
|
||||||
SET channel_id = (
|
|
||||||
SELECT c.id
|
|
||||||
FROM channels AS c
|
|
||||||
WHERE c.legacy_room_id = messages.room_id
|
|
||||||
LIMIT 1
|
|
||||||
)
|
|
||||||
WHERE channel_id IS NULL
|
|
||||||
AND room_id IS NOT NULL
|
|
||||||
AND direct_conversation_id IS NULL
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
admin_users = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM users
|
|
||||||
WHERE role = 'admin'
|
|
||||||
AND status = 'active'
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
for admin_user in admin_users:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO team_members (
|
|
||||||
team_id,
|
|
||||||
user_id,
|
|
||||||
membership_role,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, 'admin', ?)
|
|
||||||
""",
|
|
||||||
(general_team_id, admin_user["id"], now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_profiles (
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'txt',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
NULL,
|
|
||||||
?
|
|
||||||
FROM users
|
|
||||||
""",
|
|
||||||
(now_iso(),),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_settings (
|
|
||||||
user_id,
|
|
||||||
accent_color,
|
|
||||||
name_color,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
updated_at
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
'#f7dce8',
|
|
||||||
NULL,
|
|
||||||
'',
|
|
||||||
'dark',
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
'public',
|
|
||||||
'everyone',
|
|
||||||
'online',
|
|
||||||
'cards',
|
|
||||||
1,
|
|
||||||
?
|
|
||||||
FROM users
|
|
||||||
""",
|
|
||||||
(now_iso(),),
|
|
||||||
)
|
|
||||||
|
|
||||||
existing_message_attachment_counts = db.execute(
|
|
||||||
"SELECT COUNT(*) AS count FROM message_attachments"
|
|
||||||
).fetchone()["count"]
|
|
||||||
|
|
||||||
if existing_message_attachment_counts == 0:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, attachment_id
|
|
||||||
FROM messages
|
|
||||||
WHERE attachment_id IS NOT NULL
|
|
||||||
ORDER BY id ASC
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
for row in rows:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO message_attachments (
|
|
||||||
message_id,
|
|
||||||
attachment_id,
|
|
||||||
position
|
|
||||||
) VALUES (?, ?, 0)
|
|
||||||
""",
|
|
||||||
(row["id"], row["attachment_id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_admin() -> None:
|
|
||||||
username = os.environ.get("ADMIN_USERNAME", "").strip().lower()
|
|
||||||
password = os.environ.get("ADMIN_PASSWORD", "")
|
|
||||||
|
|
||||||
if not username or not password:
|
|
||||||
return
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
existing = db.execute(
|
|
||||||
"SELECT id FROM users WHERE username = ?",
|
|
||||||
(username,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if existing:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
role = 'admin',
|
|
||||||
status = 'active',
|
|
||||||
is_admin = 1,
|
|
||||||
is_active = 1
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(existing["id"],),
|
|
||||||
)
|
|
||||||
user_id = existing["id"]
|
|
||||||
else:
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO users (
|
|
||||||
username,
|
|
||||||
password_hash,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
is_admin,
|
|
||||||
is_active,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, 'admin', 'active', 1, 1, ?)
|
|
||||||
""",
|
|
||||||
(username, generate_password_hash(password), now_iso()),
|
|
||||||
)
|
|
||||||
user_id = cursor.lastrowid
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_profiles (
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, '', '', 'txt', '', '', '', NULL, ?)
|
|
||||||
""",
|
|
||||||
(user_id, username, now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO user_settings (
|
|
||||||
user_id,
|
|
||||||
accent_color,
|
|
||||||
name_color,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, '#f7dce8', NULL, '', 'dark', NULL, NULL, 'public', 'everyone', 'online', 'cards', 1, ?)
|
|
||||||
""",
|
|
||||||
(user_id, now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
@ -1,13 +1,18 @@
|
|||||||
desktop app
|
desktop app
|
||||||
|
|
||||||
- secure qtwebengine browser
|
|
||||||
- stores login/session data beside the app, between restarts
|
|
||||||
- blocks external navigation/popups/downloads
|
|
||||||
- direct or proxy mode
|
|
||||||
|
|
||||||
---
|
- secure qtwebengine browser instead of web browser
|
||||||
|
- allows only approved https
|
||||||
|
- blocks external navigation
|
||||||
|
- blocks popups
|
||||||
|
- blocks downloads
|
||||||
|
- denies browser permission requests
|
||||||
|
- rejects bad tls/certificate errors
|
||||||
|
- keeps a single app instance
|
||||||
|
- stores login/session data beside the app, keeps between restarts
|
||||||
|
- logs startup/runtime errors
|
||||||
|
|
||||||
##### install
|
##### requirements
|
||||||
|
|
||||||
install python packages:
|
install python packages:
|
||||||
|
|
||||||
@ -29,7 +34,7 @@ python main.py
|
|||||||
|
|
||||||
##### data
|
##### data
|
||||||
|
|
||||||
local app data stored beside the script:
|
local app data is stored beside the script or bundled binary:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
angelhome_data/
|
angelhome_data/
|
||||||
@ -39,10 +44,11 @@ angelhome_data/
|
|||||||
storage/
|
storage/
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
this is used for:
|
||||||
|
|
||||||
|
- session persistence
|
||||||
|
- cache
|
||||||
|
- logs
|
||||||
|
- runtime lock file
|
||||||
|
|
||||||
```
|
```
|
||||||
proxy mode starts local proxy, opens site through the local proxy
|
|
||||||
direct mode connects to url directly, normal login/chat/session behavior
|
|
||||||
```
|
|
||||||
|
|||||||
1219
desktop/main.py
1219
desktop/main.py
File diff suppressed because it is too large
Load Diff
@ -19,11 +19,11 @@ services:
|
|||||||
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
|
FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-}
|
||||||
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
|
CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-}
|
||||||
PRIVATE_ACCESS_KEY: ${PRIVATE_ACCESS_KEY:-}
|
PRIVATE_ACCESS_KEY: ${PRIVATE_ACCESS_KEY:-}
|
||||||
|
|
||||||
|
# VPS2 helper integration
|
||||||
CALLS_BASE: ${CALLS_BASE:-https://calls.backend-3.com}
|
CALLS_BASE: ${CALLS_BASE:-https://calls.backend-3.com}
|
||||||
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET:-}
|
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET:-}
|
||||||
CALL_TOKEN_TTL_SECONDS: ${CALL_TOKEN_TTL_SECONDS:-60}
|
CALL_TOKEN_TTL_SECONDS: ${CALL_TOKEN_TTL_SECONDS:-60}
|
||||||
# set CALLS_BASE to turn server config
|
|
||||||
# helper/.env
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- chat_data:/app/data
|
- chat_data:/app/data
|
||||||
@ -43,10 +43,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 20s
|
start_period: 20s
|
||||||
labels:
|
labels:
|
||||||
caddy: ${DOMAIN_URL:-angel.33.backend-3.com}
|
caddy: 300.backend-3.com
|
||||||
caddy.reverse_proxy: "{{upstreams 5000}}"
|
caddy.reverse_proxy: "{{upstreams 5000}}"
|
||||||
# set DOMAIN_URL to domain
|
|
||||||
# .env
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
chat_data:
|
chat_data:
|
||||||
|
|||||||
143
docs/CSS.md
143
docs/CSS.md
@ -1,148 +1,34 @@
|
|||||||
copy + paste themes in settings > custom css
|
themes
|
||||||
|
|
||||||
```
|
<pre>
|
||||||
ios amoled black / soft glass / lowercase
|
lowercase classic
|
||||||
|
monospace
|
||||||
|
|
||||||
* {
|
* {
|
||||||
text-transform: lowercase;
|
|
||||||
font-family: "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
|
|
||||||
font-size: 95.7%;
|
|
||||||
font-weight: 445 !important;
|
|
||||||
letter-spacing: -0.012em;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
color: rgba(245, 240, 255, 0.93);
|
|
||||||
background: transparent !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
font-size: 94.5%;
|
|
||||||
font-weight: 450 !important;
|
|
||||||
line-height: 1.23rem;
|
|
||||||
text-shadow: 0 0 20px rgba(255, 230, 245, 0.33);
|
|
||||||
backdrop-filter: blur(12px) saturate(0.33) brightness(0.33);
|
|
||||||
-webkit-backdrop-filter: blur(12px) saturate(1) brightness(0.99);
|
|
||||||
background: rgba(20, 23, 20, 0) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.86;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
ios amoled black / lowercase
|
|
||||||
|
|
||||||
* {
|
|
||||||
text-transform: lowercase;
|
|
||||||
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
|
|
||||||
font-size: 97%;
|
|
||||||
font-weight: 440 !important;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: geometricprecision;
|
|
||||||
color: rgba(255, 255, 255, 0.94);
|
|
||||||
background: #000 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
font-size: 96%;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
line-height: 1.26rem;
|
|
||||||
text-shadow: 0px 0px 18px rgba(255, 255, 255, 0.1);
|
|
||||||
backdrop-filter: blur(4px) saturate(1.01) brightness(0.82);
|
|
||||||
-webkit-backdrop-filter: blur(4px) saturate(1.01) brightness(0.82);
|
|
||||||
background: rgba(0, 0, 0, 0.88) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.88;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
ios / lowercase
|
|
||||||
|
|
||||||
* {
|
|
||||||
text-transform: lowercase;
|
|
||||||
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
|
|
||||||
font-size: 97%;
|
|
||||||
font-weight: 440 !important;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
text-rendering: geometricprecision;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
font-size: 96%;
|
|
||||||
font-weight: 500 !important;
|
|
||||||
line-height: 1.26rem;
|
|
||||||
text-shadow: 0px 0px 22px rgba(255, 255, 255, 0.16);
|
|
||||||
backdrop-filter: blur(6px) saturate(1.02);
|
|
||||||
-webkit-backdrop-filter: blur(6px) saturate(1.02);
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
classic / monospace / lowercase
|
|
||||||
|
|
||||||
* {
|
|
||||||
font-weight: 500 !important;
|
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
letter-spacing: -0.02em;
|
font-size: 94%;
|
||||||
font-size: 96.7%;
|
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
font-size: 96%;
|
font-size: 95%;
|
||||||
font-weight: 500 !important;
|
font-weight: 600 !important;
|
||||||
text-shadow: 0px 3px 33px #fff;
|
text-shadow: 0px 3px 33px #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
|
||||||
wood / lowercase
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
text-transform: lowercase;
|
font-weight: 400 !important;
|
||||||
font-family: "SF Pro Text", system-ui, "Helvetica Neue", sans-serif;
|
|
||||||
font-size: 96%;
|
|
||||||
font-weight: 435 !important;
|
|
||||||
letter-spacing: -0.01em;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
color: rgba(255, 245, 230, 0.92);
|
|
||||||
background: TRANSPARENT !important;
|
|
||||||
}
|
}
|
||||||
|
</pre>
|
||||||
|
|
||||||
div {
|
<pre>
|
||||||
font-size: 96%;
|
lowercase modern
|
||||||
font-weight: 500 !important;
|
helvetica
|
||||||
line-height: 1.27rem;
|
|
||||||
text-shadow: 0 0 12px rgba(255, 220, 180, 0.05);
|
|
||||||
backdrop-filter: blur(5px) saturate(0.96) brightness(0.88);
|
|
||||||
-webkit-backdrop-filter: blur(5px) saturate(1.0) brightness(1.0);
|
|
||||||
background: rgba(18, 14, 11, 0.9) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
opacity: 0.81;
|
|
||||||
}
|
|
||||||
|
|
||||||
```
|
|
||||||
```
|
|
||||||
classic / helvetica / lowercase
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
font-weight: 500 !important;
|
font-weight: 500 !important;
|
||||||
text-transform: lowercase;
|
text-transform: lowercase;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 100%;
|
font-size: 100%;
|
||||||
font-family: "SF Sans", helvetica, system-ui, monospace;
|
font-family: "SF SANS", HELVETICA, SYSTEM-UI, MONOSPACE;
|
||||||
}
|
}
|
||||||
div {
|
div {
|
||||||
font-size: 96%;
|
font-size: 96%;
|
||||||
@ -150,5 +36,4 @@ div {
|
|||||||
line-height: 1.2rem;
|
line-height: 1.2rem;
|
||||||
text-shadow: 0px 0px 75px #fff;
|
text-shadow: 0px 0px 75px #fff;
|
||||||
}
|
}
|
||||||
|
</pre>
|
||||||
```
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
[themes](CSS.md)
|
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
ver. 1.6
|
|
||||||
|
v1.0
|
||||||
|
|
||||||
- sign in, register, logout, session check
|
- sign in, register, logout, session check
|
||||||
- private access key for sign up
|
- private access key for sign up
|
||||||
@ -24,14 +23,16 @@ ver. 1.6
|
|||||||
- add multiple attachments per message
|
- add multiple attachments per message
|
||||||
- image, audio, video, and file previews
|
- image, audio, video, and file previews
|
||||||
- attachment downloads
|
- attachment downloads
|
||||||
|
- unread message counter
|
||||||
|
- read receipts in settings
|
||||||
- jump to latest
|
- jump to latest
|
||||||
|
|
||||||
- add teams
|
- add teams
|
||||||
- team channels
|
- team channels
|
||||||
- team member management
|
- team member management
|
||||||
- team invite links
|
- team invite links
|
||||||
- team pages
|
|
||||||
|
|
||||||
|
- team pages
|
||||||
- create, edit, read, and delete team pages
|
- create, edit, read, and delete team pages
|
||||||
- page types: project, document, update, resource
|
- page types: project, document, update, resource
|
||||||
- page filters and visibility settings
|
- page filters and visibility settings
|
||||||
@ -44,8 +45,7 @@ ver. 1.6
|
|||||||
- join date
|
- join date
|
||||||
|
|
||||||
- user settings
|
- user settings
|
||||||
- dark and light themes
|
- dark and light theme
|
||||||
- presence controls
|
|
||||||
- custom css
|
- custom css
|
||||||
- accent color
|
- accent color
|
||||||
- username color
|
- username color
|
||||||
@ -65,15 +65,11 @@ ver. 1.6
|
|||||||
- persistent database and uploads
|
- persistent database and uploads
|
||||||
- rate limiting
|
- rate limiting
|
||||||
|
|
||||||
- calls
|
|
||||||
- mute, leave, end call controls
|
|
||||||
- group calls for rooms and channels
|
|
||||||
|
|
||||||
|
|
||||||
*needs testing*
|
*needs testing*
|
||||||
- dm calls
|
- dm calling api
|
||||||
|
- group voice calls for rooms and channels
|
||||||
- call invites
|
- call invites
|
||||||
|
- mute and hangup controls
|
||||||
- reconnect handling
|
- reconnect handling
|
||||||
- desktop client
|
|
||||||
|
|
||||||
<pre>
|
<pre>
|
||||||
1423
features.py
Normal file
1423
features.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,18 +0,0 @@
|
|||||||
CALL_HELPER_SHARED_SECRET=replace-with-same-secret-as-main-app
|
|
||||||
|
|
||||||
PUBLIC_BASE=https://calls.backend-3.com
|
|
||||||
HELPER_LOG_LEVEL=info
|
|
||||||
HELPER_DEFAULT_ROUTE_MODE=direct
|
|
||||||
|
|
||||||
ENABLE_OVERLAY_METADATA=1
|
|
||||||
|
|
||||||
OVERLAY_YGG_ENABLED=0
|
|
||||||
OVERLAY_YGG_BRIDGE_WSS=
|
|
||||||
OVERLAY_YGG_BRIDGE_LABEL=ygg-bridge
|
|
||||||
|
|
||||||
OVERLAY_I2P_ENABLED=0
|
|
||||||
OVERLAY_I2P_BRIDGE_HTTPS=
|
|
||||||
OVERLAY_I2P_BRIDGE_LABEL=i2p-bridge
|
|
||||||
|
|
||||||
RELAY_ENABLED=1
|
|
||||||
RELAY_WSS=
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
FROM node:20-alpine
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install --omit=dev
|
|
||||||
|
|
||||||
COPY server.js ./
|
|
||||||
COPY scripts ./scripts
|
|
||||||
|
|
||||||
ENV PORT=8080
|
|
||||||
EXPOSE 8080
|
|
||||||
|
|
||||||
CMD ["npm", "start"]
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
services:
|
|
||||||
calls-helper:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: calls-helper
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
PORT: 8080
|
|
||||||
HOST: 0.0.0.0
|
|
||||||
PUBLIC_BASE: ${PUBLIC_BASE:-https://calls.backend-3.com}
|
|
||||||
CALL_HELPER_SHARED_SECRET: ${CALL_HELPER_SHARED_SECRET}
|
|
||||||
HELPER_LOG_LEVEL: ${HELPER_LOG_LEVEL:-info}
|
|
||||||
HELPER_DEFAULT_ROUTE_MODE: ${HELPER_DEFAULT_ROUTE_MODE:-direct}
|
|
||||||
|
|
||||||
ENABLE_OVERLAY_METADATA: ${ENABLE_OVERLAY_METADATA:-1}
|
|
||||||
|
|
||||||
OVERLAY_YGG_ENABLED: ${OVERLAY_YGG_ENABLED:-0}
|
|
||||||
OVERLAY_YGG_BRIDGE_WSS: ${OVERLAY_YGG_BRIDGE_WSS:-}
|
|
||||||
OVERLAY_YGG_BRIDGE_LABEL: ${OVERLAY_YGG_BRIDGE_LABEL:-ygg-bridge}
|
|
||||||
|
|
||||||
OVERLAY_I2P_ENABLED: ${OVERLAY_I2P_ENABLED:-0}
|
|
||||||
OVERLAY_I2P_BRIDGE_HTTPS: ${OVERLAY_I2P_BRIDGE_HTTPS:-}
|
|
||||||
OVERLAY_I2P_BRIDGE_LABEL: ${OVERLAY_I2P_BRIDGE_LABEL:-i2p-bridge}
|
|
||||||
|
|
||||||
RELAY_ENABLED: ${RELAY_ENABLED:-1}
|
|
||||||
RELAY_WSS: ${RELAY_WSS:-}
|
|
||||||
restart: unless-stopped
|
|
||||||
networks:
|
|
||||||
- privatenet
|
|
||||||
labels:
|
|
||||||
caddy: calls.backend-3.com
|
|
||||||
caddy.reverse_proxy: "{{upstreams 8080}}"
|
|
||||||
|
|
||||||
networks:
|
|
||||||
privatenet:
|
|
||||||
external: true
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "calls-helper",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node server.js",
|
|
||||||
"gen:secret": "node scripts/generate-secret.js"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"express": "^4.21.0",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
|
|
||||||
const value = crypto.randomBytes(48).toString("base64url");
|
|
||||||
console.log(value);
|
|
||||||
761
helper/server.js
761
helper/server.js
@ -1,761 +0,0 @@
|
|||||||
import crypto from "crypto";
|
|
||||||
import express from "express";
|
|
||||||
import http from "http";
|
|
||||||
import { WebSocketServer } from "ws";
|
|
||||||
|
|
||||||
const TURN_URLS = String(process.env.TURN_URLS || "")
|
|
||||||
.split(",")
|
|
||||||
.map(value => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const TURN_USERNAME = String(process.env.TURN_USERNAME || "").trim();
|
|
||||||
const TURN_PASSWORD = String(process.env.TURN_PASSWORD || "").trim();
|
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 8080);
|
|
||||||
const HOST = process.env.HOST || "0.0.0.0";
|
|
||||||
|
|
||||||
const CALL_HELPER_SHARED_SECRET = String(
|
|
||||||
process.env.CALL_HELPER_SHARED_SECRET || ""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const PUBLIC_BASE = String(
|
|
||||||
process.env.PUBLIC_BASE || `http://127.0.0.1:${PORT}`
|
|
||||||
).replace(/\/+$/, "");
|
|
||||||
|
|
||||||
const HELPER_LOG_LEVEL = String(process.env.HELPER_LOG_LEVEL || "info").toLowerCase();
|
|
||||||
|
|
||||||
const HELPER_DEFAULT_ROUTE_MODE = String(
|
|
||||||
process.env.HELPER_DEFAULT_ROUTE_MODE || "direct"
|
|
||||||
).toLowerCase();
|
|
||||||
|
|
||||||
const ENABLE_OVERLAY_METADATA = String(
|
|
||||||
process.env.ENABLE_OVERLAY_METADATA || "1"
|
|
||||||
) === "1";
|
|
||||||
|
|
||||||
const OVERLAY_YGG_ENABLED = String(
|
|
||||||
process.env.OVERLAY_YGG_ENABLED || "0"
|
|
||||||
) === "1";
|
|
||||||
|
|
||||||
const OVERLAY_YGG_BRIDGE_WSS = String(
|
|
||||||
process.env.OVERLAY_YGG_BRIDGE_WSS || ""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const OVERLAY_YGG_BRIDGE_LABEL = String(
|
|
||||||
process.env.OVERLAY_YGG_BRIDGE_LABEL || "ygg-bridge"
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const OVERLAY_I2P_ENABLED = String(
|
|
||||||
process.env.OVERLAY_I2P_ENABLED || "0"
|
|
||||||
) === "1";
|
|
||||||
|
|
||||||
const OVERLAY_I2P_BRIDGE_HTTPS = String(
|
|
||||||
process.env.OVERLAY_I2P_BRIDGE_HTTPS || ""
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const OVERLAY_I2P_BRIDGE_LABEL = String(
|
|
||||||
process.env.OVERLAY_I2P_BRIDGE_LABEL || "i2p-bridge"
|
|
||||||
).trim();
|
|
||||||
|
|
||||||
const RELAY_ENABLED = String(process.env.RELAY_ENABLED || "1") === "1";
|
|
||||||
const RELAY_WSS = String(process.env.RELAY_WSS || "").trim();
|
|
||||||
|
|
||||||
const CORS_ALLOWED_ORIGINS = String(
|
|
||||||
process.env.CORS_ALLOWED_ORIGINS || "https://angel.33.backend-3.com"
|
|
||||||
)
|
|
||||||
.split(",")
|
|
||||||
.map(value => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const allowedOrigins = new Set(CORS_ALLOWED_ORIGINS);
|
|
||||||
|
|
||||||
if (!CALL_HELPER_SHARED_SECRET) {
|
|
||||||
console.error("missing CALL_HELPER_SHARED_SECRET");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(express.json({ limit: "64kb" }));
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
|
||||||
const wss = new WebSocketServer({ noServer: true });
|
|
||||||
|
|
||||||
/**
|
|
||||||
* in-memory state
|
|
||||||
*
|
|
||||||
* calls:
|
|
||||||
* callId -> {
|
|
||||||
* callId,
|
|
||||||
* room,
|
|
||||||
* routeMode,
|
|
||||||
* participants: Map(peerId -> participant),
|
|
||||||
* createdAt,
|
|
||||||
* updatedAt
|
|
||||||
* }
|
|
||||||
*
|
|
||||||
* tickets:
|
|
||||||
* ticket -> {
|
|
||||||
* ticket,
|
|
||||||
* callId,
|
|
||||||
* peerId,
|
|
||||||
* sub,
|
|
||||||
* username,
|
|
||||||
* role,
|
|
||||||
* room,
|
|
||||||
* exp,
|
|
||||||
* routeProfile
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
const calls = new Map();
|
|
||||||
const tickets = new Map();
|
|
||||||
const recentLogs = [];
|
|
||||||
const MAX_LOGS = 500;
|
|
||||||
|
|
||||||
function nowMs() {
|
|
||||||
return Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowSeconds() {
|
|
||||||
return Math.floor(Date.now() / 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isoNow() {
|
|
||||||
return new Date().toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function log(level, message, meta = {}) {
|
|
||||||
const allowed = ["debug", "info", "warn", "error"];
|
|
||||||
const currentIdx = Math.max(0, allowed.indexOf(HELPER_LOG_LEVEL));
|
|
||||||
const entryIdx = Math.max(0, allowed.indexOf(level));
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
ts: isoNow(),
|
|
||||||
level,
|
|
||||||
message,
|
|
||||||
meta
|
|
||||||
};
|
|
||||||
|
|
||||||
recentLogs.push(item);
|
|
||||||
if (recentLogs.length > MAX_LOGS) {
|
|
||||||
recentLogs.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entryIdx >= currentIdx) {
|
|
||||||
const method =
|
|
||||||
level === "error" ? "error" :
|
|
||||||
level === "warn" ? "warn" :
|
|
||||||
"log";
|
|
||||||
console[method](`[${item.ts}] [${level}] ${message}`, meta);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowedOrigin(origin) {
|
|
||||||
if (!origin) return false;
|
|
||||||
return allowedOrigins.has(origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyCors(req, res) {
|
|
||||||
const origin = String(req.headers.origin || "").trim();
|
|
||||||
if (origin && isAllowedOrigin(origin)) {
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
||||||
res.setHeader("Vary", "Origin");
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
applyCors(req, res);
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return res.status(204).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
function b64urlDecode(value) {
|
|
||||||
const pad = value.length % 4 === 0 ? "" : "=".repeat(4 - (value.length % 4));
|
|
||||||
const normalized = value.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
|
||||||
return Buffer.from(normalized, "base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
function verifySignedToken(token) {
|
|
||||||
if (!token || typeof token !== "string" || !token.includes(".")) {
|
|
||||||
throw new Error("invalid token format");
|
|
||||||
}
|
|
||||||
|
|
||||||
const [payloadPart, sigPart] = token.split(".", 2);
|
|
||||||
const payloadBytes = b64urlDecode(payloadPart);
|
|
||||||
const sigBytes = b64urlDecode(sigPart);
|
|
||||||
|
|
||||||
const expected = crypto
|
|
||||||
.createHmac("sha256", CALL_HELPER_SHARED_SECRET)
|
|
||||||
.update(payloadBytes)
|
|
||||||
.digest();
|
|
||||||
|
|
||||||
if (
|
|
||||||
sigBytes.length !== expected.length ||
|
|
||||||
!crypto.timingSafeEqual(sigBytes, expected)
|
|
||||||
) {
|
|
||||||
throw new Error("invalid token signature");
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload;
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(payloadBytes.toString("utf8"));
|
|
||||||
} catch {
|
|
||||||
throw new Error("invalid token payload json");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload || typeof payload !== "object") {
|
|
||||||
throw new Error("invalid token payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.exp || Number(payload.exp) <= nowSeconds()) {
|
|
||||||
throw new Error("token expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.can_call) {
|
|
||||||
throw new Error("token not authorized for calls");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!payload.call_id || !payload.sub) {
|
|
||||||
throw new Error("token missing required fields");
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomId(prefix = "") {
|
|
||||||
return `${prefix}${crypto.randomBytes(8).toString("hex")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chooseRouteMode(payload) {
|
|
||||||
const requested = String(payload.route_mode || "").trim().toLowerCase();
|
|
||||||
|
|
||||||
if (requested === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
|
|
||||||
return "overlay_ygg";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requested === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
|
|
||||||
return "overlay_i2p";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_ygg" && OVERLAY_YGG_ENABLED) {
|
|
||||||
return "overlay_ygg";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (HELPER_DEFAULT_ROUTE_MODE === "overlay_i2p" && OVERLAY_I2P_ENABLED) {
|
|
||||||
return "overlay_i2p";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "direct";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRouteCandidates(callId) {
|
|
||||||
const routes = [];
|
|
||||||
|
|
||||||
routes.push({
|
|
||||||
kind: "direct",
|
|
||||||
target: callId,
|
|
||||||
priority: 100,
|
|
||||||
available: true,
|
|
||||||
label: "direct"
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ENABLE_OVERLAY_METADATA && OVERLAY_YGG_ENABLED) {
|
|
||||||
routes.push({
|
|
||||||
kind: "overlay_ygg",
|
|
||||||
target: OVERLAY_YGG_BRIDGE_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
|
|
||||||
priority: 90,
|
|
||||||
available: Boolean(OVERLAY_YGG_BRIDGE_WSS),
|
|
||||||
label: OVERLAY_YGG_BRIDGE_LABEL
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ENABLE_OVERLAY_METADATA && OVERLAY_I2P_ENABLED) {
|
|
||||||
routes.push({
|
|
||||||
kind: "overlay_i2p",
|
|
||||||
target: OVERLAY_I2P_BRIDGE_HTTPS || PUBLIC_BASE,
|
|
||||||
priority: 40,
|
|
||||||
available: Boolean(OVERLAY_I2P_BRIDGE_HTTPS),
|
|
||||||
label: OVERLAY_I2P_BRIDGE_LABEL
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (RELAY_ENABLED) {
|
|
||||||
routes.push({
|
|
||||||
kind: "relay",
|
|
||||||
target: RELAY_WSS || PUBLIC_BASE.replace(/^http/, "ws"),
|
|
||||||
priority: 20,
|
|
||||||
available: true,
|
|
||||||
label: "relay"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTransportPolicy(routeMode) {
|
|
||||||
const order = [];
|
|
||||||
|
|
||||||
if (routeMode === "overlay_ygg") {
|
|
||||||
order.push("overlay_ygg", "direct", "relay");
|
|
||||||
} else if (routeMode === "overlay_i2p") {
|
|
||||||
order.push("direct", "relay");
|
|
||||||
} else {
|
|
||||||
order.push("direct", "relay");
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
preferred_order: order,
|
|
||||||
allow_relay_fallback: RELAY_ENABLED,
|
|
||||||
private_service_transport: OVERLAY_I2P_ENABLED ? "overlay_i2p" : null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIceServers() {
|
|
||||||
const servers = [
|
|
||||||
{
|
|
||||||
urls: ["stun:stun.l.google.com:19302"]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
if (TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD) {
|
|
||||||
servers.push({
|
|
||||||
urls: TURN_URLS,
|
|
||||||
username: TURN_USERNAME,
|
|
||||||
credential: TURN_PASSWORD
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return servers;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateCall(callId, room, routeMode = "direct") {
|
|
||||||
let call = calls.get(callId);
|
|
||||||
|
|
||||||
if (!call) {
|
|
||||||
call = {
|
|
||||||
callId,
|
|
||||||
room,
|
|
||||||
routeMode,
|
|
||||||
participants: new Map(),
|
|
||||||
createdAt: nowMs(),
|
|
||||||
updatedAt: nowMs()
|
|
||||||
};
|
|
||||||
calls.set(callId, call);
|
|
||||||
log("info", "created call", { callId, room, routeMode });
|
|
||||||
}
|
|
||||||
|
|
||||||
return call;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWsUrl(ticket) {
|
|
||||||
const publicBase = PUBLIC_BASE.replace(/^http:/, "ws:").replace(/^https:/, "wss:");
|
|
||||||
return `${publicBase}/ws?ticket=${encodeURIComponent(ticket)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendJson(ws, payload) {
|
|
||||||
if (!ws || ws.readyState !== ws.OPEN) return;
|
|
||||||
ws.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcast(call, payload, exceptPeerId = null) {
|
|
||||||
for (const [peerId, participant] of call.participants.entries()) {
|
|
||||||
if (exceptPeerId && peerId === exceptPeerId) continue;
|
|
||||||
sendJson(participant.ws, payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function callSnapshot(call) {
|
|
||||||
return {
|
|
||||||
call_id: call.callId,
|
|
||||||
room: call.room,
|
|
||||||
route_mode: call.routeMode,
|
|
||||||
participant_count: call.participants.size,
|
|
||||||
created_at: new Date(call.createdAt).toISOString(),
|
|
||||||
updated_at: new Date(call.updatedAt).toISOString(),
|
|
||||||
participants: [...call.participants.values()].map(participant => ({
|
|
||||||
peer_id: participant.peerId,
|
|
||||||
sub: participant.sub,
|
|
||||||
username: participant.username,
|
|
||||||
role: participant.role,
|
|
||||||
route_mode: participant.routeMode,
|
|
||||||
joined_at: new Date(participant.joinedAt).toISOString()
|
|
||||||
}))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusSnapshot() {
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
ts: isoNow(),
|
|
||||||
public_base: PUBLIC_BASE,
|
|
||||||
route_defaults: {
|
|
||||||
default_route_mode: HELPER_DEFAULT_ROUTE_MODE,
|
|
||||||
overlay_metadata_enabled: ENABLE_OVERLAY_METADATA,
|
|
||||||
relay_enabled: RELAY_ENABLED
|
|
||||||
},
|
|
||||||
overlays: {
|
|
||||||
ygg: {
|
|
||||||
enabled: OVERLAY_YGG_ENABLED,
|
|
||||||
bridge_label: OVERLAY_YGG_BRIDGE_LABEL,
|
|
||||||
bridge_wss: OVERLAY_YGG_BRIDGE_WSS || null
|
|
||||||
},
|
|
||||||
i2p: {
|
|
||||||
enabled: OVERLAY_I2P_ENABLED,
|
|
||||||
bridge_label: OVERLAY_I2P_BRIDGE_LABEL,
|
|
||||||
bridge_https: OVERLAY_I2P_BRIDGE_HTTPS || null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
turn: {
|
|
||||||
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
|
|
||||||
urls: TURN_URLS
|
|
||||||
},
|
|
||||||
cors: {
|
|
||||||
allowed_origins: [...allowedOrigins]
|
|
||||||
},
|
|
||||||
counts: {
|
|
||||||
active_calls: calls.size,
|
|
||||||
active_tickets: tickets.size
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeParticipant(callId, peerId, notify = true) {
|
|
||||||
const call = calls.get(callId);
|
|
||||||
if (!call) return;
|
|
||||||
|
|
||||||
const participant = call.participants.get(peerId);
|
|
||||||
if (!participant) return;
|
|
||||||
|
|
||||||
call.participants.delete(peerId);
|
|
||||||
call.updatedAt = nowMs();
|
|
||||||
|
|
||||||
log("info", "participant removed", {
|
|
||||||
callId,
|
|
||||||
peerId,
|
|
||||||
username: participant.username
|
|
||||||
});
|
|
||||||
|
|
||||||
if (notify) {
|
|
||||||
broadcast(call, {
|
|
||||||
type: "participant-left",
|
|
||||||
participantId: peerId,
|
|
||||||
roomId: callId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (call.participants.size === 0) {
|
|
||||||
calls.delete(callId);
|
|
||||||
log("info", "call removed because empty", { callId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get("/healthz", (req, res) => {
|
|
||||||
applyCors(req, res);
|
|
||||||
res.json(statusSnapshot());
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/status", (req, res) => {
|
|
||||||
applyCors(req, res);
|
|
||||||
res.json({
|
|
||||||
...statusSnapshot(),
|
|
||||||
calls: [...calls.values()].map(callSnapshot)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get("/logs", (req, res) => {
|
|
||||||
applyCors(req, res);
|
|
||||||
const limit = Math.max(1, Math.min(200, Number(req.query.limit || 50)));
|
|
||||||
res.json({
|
|
||||||
ok: true,
|
|
||||||
logs: recentLogs.slice(-limit)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post("/api/call/session", (req, res) => {
|
|
||||||
applyCors(req, res);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const token = req.body?.token;
|
|
||||||
const payload = verifySignedToken(token);
|
|
||||||
|
|
||||||
const callId = String(payload.call_id);
|
|
||||||
const room = String(payload.room || callId);
|
|
||||||
const peerId = randomId("peer_");
|
|
||||||
const ticket = randomId("ticket_");
|
|
||||||
const routeMode = chooseRouteMode(payload);
|
|
||||||
const routeCandidates = buildRouteCandidates(callId);
|
|
||||||
const transportPolicy = buildTransportPolicy(routeMode);
|
|
||||||
|
|
||||||
getOrCreateCall(callId, room, routeMode);
|
|
||||||
|
|
||||||
tickets.set(ticket, {
|
|
||||||
ticket,
|
|
||||||
callId,
|
|
||||||
peerId,
|
|
||||||
sub: String(payload.sub),
|
|
||||||
username: String(payload.username || peerId),
|
|
||||||
role: String(payload.role || "user"),
|
|
||||||
room,
|
|
||||||
exp: Number(payload.exp),
|
|
||||||
routeProfile: {
|
|
||||||
routeMode,
|
|
||||||
routeCandidates,
|
|
||||||
transportPolicy
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
log("info", "created call session ticket", {
|
|
||||||
callId,
|
|
||||||
peerId,
|
|
||||||
username: payload.username,
|
|
||||||
routeMode,
|
|
||||||
origin: req.headers.origin || null,
|
|
||||||
turnEnabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD)
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
ok: true,
|
|
||||||
call_id: callId,
|
|
||||||
peer_id: peerId,
|
|
||||||
ws_url: buildWsUrl(ticket),
|
|
||||||
iceServers: getIceServers(),
|
|
||||||
transport_policy: transportPolicy,
|
|
||||||
route_candidates: routeCandidates
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
log("warn", "call session rejected", {
|
|
||||||
error: error.message,
|
|
||||||
origin: req.headers.origin || null
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(401).json({
|
|
||||||
ok: false,
|
|
||||||
error: error.message || "invalid call token"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
server.on("upgrade", (req, socket, head) => {
|
|
||||||
const origin = String(req.headers.origin || "").trim();
|
|
||||||
|
|
||||||
if (origin && !isAllowedOrigin(origin)) {
|
|
||||||
log("warn", "websocket upgrade rejected by origin", { origin });
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
||||||
if (url.pathname !== "/ws") {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticket = url.searchParams.get("ticket");
|
|
||||||
const ticketData = ticket ? tickets.get(ticket) : null;
|
|
||||||
|
|
||||||
if (!ticketData || ticketData.exp <= nowSeconds()) {
|
|
||||||
log("warn", "websocket upgrade rejected by invalid/expired ticket", {
|
|
||||||
origin: origin || null
|
|
||||||
});
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wss.handleUpgrade(req, socket, head, ws => {
|
|
||||||
ws._ticketData = ticketData;
|
|
||||||
wss.emit("connection", ws, req);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
wss.on("connection", ws => {
|
|
||||||
const ticketData = ws._ticketData;
|
|
||||||
if (!ticketData) {
|
|
||||||
ws.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
tickets.delete(ticketData.ticket);
|
|
||||||
|
|
||||||
const call = getOrCreateCall(
|
|
||||||
ticketData.callId,
|
|
||||||
ticketData.room,
|
|
||||||
ticketData.routeProfile?.routeMode || "direct"
|
|
||||||
);
|
|
||||||
|
|
||||||
const participant = {
|
|
||||||
peerId: ticketData.peerId,
|
|
||||||
sub: ticketData.sub,
|
|
||||||
username: ticketData.username,
|
|
||||||
role: ticketData.role,
|
|
||||||
room: ticketData.room,
|
|
||||||
routeMode: ticketData.routeProfile?.routeMode || "direct",
|
|
||||||
routeCandidates: ticketData.routeProfile?.routeCandidates || [],
|
|
||||||
transportPolicy: ticketData.routeProfile?.transportPolicy || {},
|
|
||||||
ws,
|
|
||||||
joinedAt: nowMs()
|
|
||||||
};
|
|
||||||
|
|
||||||
call.participants.set(ticketData.peerId, participant);
|
|
||||||
call.updatedAt = nowMs();
|
|
||||||
|
|
||||||
log("info", "participant joined websocket", {
|
|
||||||
callId: call.callId,
|
|
||||||
peerId: participant.peerId,
|
|
||||||
username: participant.username,
|
|
||||||
routeMode: participant.routeMode
|
|
||||||
});
|
|
||||||
|
|
||||||
sendJson(ws, {
|
|
||||||
type: "joined",
|
|
||||||
participantId: ticketData.peerId,
|
|
||||||
roomId: call.callId,
|
|
||||||
transportPolicy: participant.transportPolicy,
|
|
||||||
routeCandidates: participant.routeCandidates
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const [peerId, existing] of call.participants.entries()) {
|
|
||||||
if (peerId === ticketData.peerId) continue;
|
|
||||||
sendJson(ws, {
|
|
||||||
type: "participant-joined",
|
|
||||||
participantId: peerId,
|
|
||||||
roomId: call.callId,
|
|
||||||
username: existing.username,
|
|
||||||
routeMode: existing.routeMode
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcast(
|
|
||||||
call,
|
|
||||||
{
|
|
||||||
type: "participant-joined",
|
|
||||||
participantId: ticketData.peerId,
|
|
||||||
roomId: call.callId,
|
|
||||||
username: participant.username,
|
|
||||||
routeMode: participant.routeMode
|
|
||||||
},
|
|
||||||
ticketData.peerId
|
|
||||||
);
|
|
||||||
|
|
||||||
ws.on("message", raw => {
|
|
||||||
let msg;
|
|
||||||
try {
|
|
||||||
msg = JSON.parse(String(raw));
|
|
||||||
} catch {
|
|
||||||
log("warn", "invalid ws json from peer", {
|
|
||||||
callId: call.callId,
|
|
||||||
peerId: participant.peerId
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const senderPeerId = ticketData.peerId;
|
|
||||||
call.updatedAt = nowMs();
|
|
||||||
|
|
||||||
if (!msg || typeof msg !== "object" || !msg.type) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "hangup") {
|
|
||||||
log("info", "peer requested hangup", {
|
|
||||||
callId: call.callId,
|
|
||||||
peerId: senderPeerId
|
|
||||||
});
|
|
||||||
removeParticipant(call.callId, senderPeerId, true);
|
|
||||||
try {
|
|
||||||
ws.close();
|
|
||||||
} catch {}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "offer" || msg.type === "answer") {
|
|
||||||
const to = String(msg.to || "").trim();
|
|
||||||
if (!to) return;
|
|
||||||
|
|
||||||
const target = call.participants.get(to);
|
|
||||||
if (!target) {
|
|
||||||
log("warn", "signal target missing", {
|
|
||||||
callId: call.callId,
|
|
||||||
from: senderPeerId,
|
|
||||||
to,
|
|
||||||
type: msg.type
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(target.ws, {
|
|
||||||
type: msg.type,
|
|
||||||
from: senderPeerId,
|
|
||||||
sdp: msg.sdp,
|
|
||||||
username: participant.username,
|
|
||||||
roomId: call.callId,
|
|
||||||
routeMode: participant.routeMode
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.type === "ice") {
|
|
||||||
const to = String(msg.to || "").trim();
|
|
||||||
if (!to) return;
|
|
||||||
|
|
||||||
const target = call.participants.get(to);
|
|
||||||
if (!target) {
|
|
||||||
log("warn", "ice target missing", {
|
|
||||||
callId: call.callId,
|
|
||||||
from: senderPeerId,
|
|
||||||
to
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendJson(target.ws, {
|
|
||||||
type: "ice",
|
|
||||||
from: senderPeerId,
|
|
||||||
candidate: msg.candidate,
|
|
||||||
sdpMid: msg.sdpMid,
|
|
||||||
sdpMLineIndex: msg.sdpMLineIndex,
|
|
||||||
username: participant.username,
|
|
||||||
roomId: call.callId,
|
|
||||||
routeMode: participant.routeMode
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("close", () => {
|
|
||||||
log("info", "websocket closed", {
|
|
||||||
callId: call.callId,
|
|
||||||
peerId: participant.peerId
|
|
||||||
});
|
|
||||||
removeParticipant(call.callId, ticketData.peerId, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on("error", error => {
|
|
||||||
log("warn", "websocket error", {
|
|
||||||
callId: call.callId,
|
|
||||||
peerId: participant.peerId,
|
|
||||||
error: error?.message || "unknown"
|
|
||||||
});
|
|
||||||
removeParticipant(call.callId, ticketData.peerId, true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
server.listen(PORT, HOST, () => {
|
|
||||||
log("info", "calls helper listening", {
|
|
||||||
host: HOST,
|
|
||||||
port: PORT,
|
|
||||||
publicBase: PUBLIC_BASE,
|
|
||||||
overlays: {
|
|
||||||
ygg: OVERLAY_YGG_ENABLED,
|
|
||||||
i2p: OVERLAY_I2P_ENABLED
|
|
||||||
},
|
|
||||||
turn: {
|
|
||||||
enabled: Boolean(TURN_URLS.length && TURN_USERNAME && TURN_PASSWORD),
|
|
||||||
urls: TURN_URLS
|
|
||||||
},
|
|
||||||
corsAllowedOrigins: [...allowedOrigins]
|
|
||||||
});
|
|
||||||
});
|
|
||||||
642
models.py
642
models.py
@ -1,642 +0,0 @@
|
|||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
from config import utc_now
|
|
||||||
from db import get_db
|
|
||||||
from auth import user_payload
|
|
||||||
|
|
||||||
ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$")
|
|
||||||
|
|
||||||
|
|
||||||
MESSAGE_SELECT = """
|
|
||||||
SELECT
|
|
||||||
m.id,
|
|
||||||
m.room_id,
|
|
||||||
m.channel_id,
|
|
||||||
m.direct_conversation_id,
|
|
||||||
m.user_id,
|
|
||||||
m.username,
|
|
||||||
m.text,
|
|
||||||
m.created_at,
|
|
||||||
m.attachment_id,
|
|
||||||
m.parent_message_id,
|
|
||||||
(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM messages AS thread_reply
|
|
||||||
WHERE thread_reply.parent_message_id = m.id
|
|
||||||
) AS thread_reply_count,
|
|
||||||
a.original_name AS attachment_original_name,
|
|
||||||
a.mime_type AS attachment_mime_type,
|
|
||||||
a.size_bytes AS attachment_size_bytes,
|
|
||||||
u.role AS user_role,
|
|
||||||
us.name_color AS user_name_color
|
|
||||||
FROM messages AS m
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = m.user_id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = u.id
|
|
||||||
LEFT JOIN attachments AS a
|
|
||||||
ON a.id = m.attachment_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def team_page_select() -> str:
|
|
||||||
has_parent_page = False
|
|
||||||
try:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute("PRAGMA table_info(pages)").fetchall()
|
|
||||||
has_parent_page = any(row["name"] == "parent_page_id" for row in rows)
|
|
||||||
except Exception:
|
|
||||||
has_parent_page = False
|
|
||||||
|
|
||||||
if has_parent_page:
|
|
||||||
return """
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.team_id,
|
|
||||||
p.channel_id,
|
|
||||||
p.parent_page_id,
|
|
||||||
parent.slug AS parent_page_slug,
|
|
||||||
parent.title AS parent_page_title,
|
|
||||||
p.slug,
|
|
||||||
p.title,
|
|
||||||
p.body,
|
|
||||||
p.visibility,
|
|
||||||
p.page_type,
|
|
||||||
p.created_by_user_id,
|
|
||||||
p.updated_by_user_id,
|
|
||||||
p.created_at,
|
|
||||||
p.updated_at,
|
|
||||||
p.is_active,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
c.slug AS channel_slug,
|
|
||||||
c.name AS channel_name
|
|
||||||
FROM pages AS p
|
|
||||||
JOIN teams AS t
|
|
||||||
ON t.id = p.team_id
|
|
||||||
LEFT JOIN channels AS c
|
|
||||||
ON c.id = p.channel_id
|
|
||||||
LEFT JOIN pages AS parent
|
|
||||||
ON parent.id = p.parent_page_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
return """
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.team_id,
|
|
||||||
p.channel_id,
|
|
||||||
NULL AS parent_page_id,
|
|
||||||
NULL AS parent_page_slug,
|
|
||||||
NULL AS parent_page_title,
|
|
||||||
p.slug,
|
|
||||||
p.title,
|
|
||||||
p.body,
|
|
||||||
p.visibility,
|
|
||||||
p.page_type,
|
|
||||||
p.created_by_user_id,
|
|
||||||
p.updated_by_user_id,
|
|
||||||
p.created_at,
|
|
||||||
p.updated_at,
|
|
||||||
p.is_active,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
c.slug AS channel_slug,
|
|
||||||
c.name AS channel_name
|
|
||||||
FROM pages AS p
|
|
||||||
JOIN teams AS t
|
|
||||||
ON t.id = p.team_id
|
|
||||||
LEFT JOIN channels AS c
|
|
||||||
ON c.id = p.channel_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def team_slug_from_name(name: str) -> str:
|
|
||||||
return re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")[:48].strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
def page_slug_from_title(title: str) -> str:
|
|
||||||
return re.sub(r"[^a-z0-9-]+", "-", title.lower()).strip("-")[:48].strip("-")
|
|
||||||
|
|
||||||
|
|
||||||
def room_by_id(room_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_by_user_id,
|
|
||||||
is_active
|
|
||||||
FROM rooms
|
|
||||||
WHERE id = ?
|
|
||||||
AND is_active = 1
|
|
||||||
""",
|
|
||||||
(room_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def get_room_by_slug(slug: str):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_by_user_id,
|
|
||||||
is_active
|
|
||||||
FROM rooms
|
|
||||||
WHERE slug = ?
|
|
||||||
AND is_active = 1
|
|
||||||
""",
|
|
||||||
(slug,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def room_member(room_id: int, user_id: int) -> bool:
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM room_members
|
|
||||||
WHERE room_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(room_id, user_id),
|
|
||||||
).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
def room_owner(room_id: int, user_id: int) -> bool:
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM room_members
|
|
||||||
WHERE room_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
AND membership_role = 'owner'
|
|
||||||
""",
|
|
||||||
(room_id, user_id),
|
|
||||||
).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_room(room, user: Optional[dict]) -> bool:
|
|
||||||
if room["visibility"] == "public":
|
|
||||||
return True
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
if user["role"] in {"moderator", "admin"}:
|
|
||||||
return True
|
|
||||||
return room_member(room["id"], user["id"])
|
|
||||||
|
|
||||||
|
|
||||||
def can_write_room(room, user: Optional[dict]) -> bool:
|
|
||||||
if not user or user["status"] != "active":
|
|
||||||
return False
|
|
||||||
return can_access_room(room, user)
|
|
||||||
|
|
||||||
|
|
||||||
def can_manage_room(room, user: Optional[dict]) -> bool:
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
if user["role"] == "admin":
|
|
||||||
return True
|
|
||||||
return room_owner(room["id"], user["id"])
|
|
||||||
|
|
||||||
|
|
||||||
def room_payload(room, user: Optional[dict]) -> dict:
|
|
||||||
return {
|
|
||||||
"id": room["id"],
|
|
||||||
"slug": room["slug"],
|
|
||||||
"name": room["name"],
|
|
||||||
"description": room["description"],
|
|
||||||
"visibility": room["visibility"],
|
|
||||||
"can_read": can_access_room(room, user),
|
|
||||||
"can_write": can_write_room(room, user),
|
|
||||||
"can_manage": can_manage_room(room, user),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_team_by_slug(slug: str):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
FROM teams
|
|
||||||
WHERE slug = ?
|
|
||||||
AND is_active = 1
|
|
||||||
""",
|
|
||||||
(slug,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def get_channel_by_id(channel_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.team_id,
|
|
||||||
c.legacy_room_id,
|
|
||||||
c.slug,
|
|
||||||
c.name,
|
|
||||||
c.description,
|
|
||||||
c.created_by_user_id,
|
|
||||||
c.created_at,
|
|
||||||
c.is_active,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
t.description AS team_description,
|
|
||||||
t.is_active AS team_is_active
|
|
||||||
FROM channels AS c
|
|
||||||
JOIN teams AS t
|
|
||||||
ON t.id = c.team_id
|
|
||||||
WHERE c.id = ?
|
|
||||||
AND c.is_active = 1
|
|
||||||
AND t.is_active = 1
|
|
||||||
""",
|
|
||||||
(channel_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def get_channel_by_slug(team_slug: str, channel_slug: str):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.team_id,
|
|
||||||
c.legacy_room_id,
|
|
||||||
c.slug,
|
|
||||||
c.name,
|
|
||||||
c.description,
|
|
||||||
c.created_by_user_id,
|
|
||||||
c.created_at,
|
|
||||||
c.is_active,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
t.description AS team_description,
|
|
||||||
t.is_active AS team_is_active
|
|
||||||
FROM channels AS c
|
|
||||||
JOIN teams AS t
|
|
||||||
ON t.id = c.team_id
|
|
||||||
WHERE t.slug = ?
|
|
||||||
AND c.slug = ?
|
|
||||||
AND t.is_active = 1
|
|
||||||
AND c.is_active = 1
|
|
||||||
""",
|
|
||||||
(team_slug, channel_slug),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def get_channel_by_legacy_room_id(room_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
return db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.team_id,
|
|
||||||
c.legacy_room_id,
|
|
||||||
c.slug,
|
|
||||||
c.name,
|
|
||||||
c.description,
|
|
||||||
c.created_by_user_id,
|
|
||||||
c.created_at,
|
|
||||||
c.is_active,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
t.description AS team_description,
|
|
||||||
t.is_active AS team_is_active
|
|
||||||
FROM channels AS c
|
|
||||||
JOIN teams AS t
|
|
||||||
ON t.id = c.team_id
|
|
||||||
WHERE c.legacy_room_id = ?
|
|
||||||
AND c.is_active = 1
|
|
||||||
AND t.is_active = 1
|
|
||||||
""",
|
|
||||||
(room_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
|
|
||||||
def team_member_role(team_id: int, user_id: int) -> Optional[str]:
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT membership_role
|
|
||||||
FROM team_members
|
|
||||||
WHERE team_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(team_id, user_id),
|
|
||||||
).fetchone()
|
|
||||||
return row["membership_role"] if row else None
|
|
||||||
|
|
||||||
|
|
||||||
def is_team_member(team_id: int, user_id: int) -> bool:
|
|
||||||
return team_member_role(team_id, user_id) is not None
|
|
||||||
|
|
||||||
|
|
||||||
def is_team_admin(team_id: int, user: Optional[dict]) -> bool:
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
if user["role"] == "admin":
|
|
||||||
return True
|
|
||||||
return team_member_role(team_id, user["id"]) in {"owner", "admin"}
|
|
||||||
|
|
||||||
|
|
||||||
def can_edit_team(team_id: int, user: Optional[dict]) -> bool:
|
|
||||||
return is_team_admin(team_id, user)
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_team(team, user: Optional[dict]) -> bool:
|
|
||||||
if not team["is_active"]:
|
|
||||||
return False
|
|
||||||
if user and user["role"] in {"moderator", "admin"}:
|
|
||||||
return True
|
|
||||||
return bool(user and is_team_member(team["id"], user["id"]))
|
|
||||||
|
|
||||||
|
|
||||||
def legacy_room_for_channel(channel):
|
|
||||||
if not channel["legacy_room_id"]:
|
|
||||||
return None
|
|
||||||
return room_by_id(channel["legacy_room_id"])
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_channel(channel, user: Optional[dict]) -> bool:
|
|
||||||
if user and user["role"] in {"moderator", "admin"}:
|
|
||||||
return True
|
|
||||||
legacy_room = legacy_room_for_channel(channel)
|
|
||||||
if legacy_room:
|
|
||||||
return can_access_room(legacy_room, user)
|
|
||||||
return bool(user and is_team_member(channel["team_id"], user["id"]))
|
|
||||||
|
|
||||||
|
|
||||||
def can_write_channel(channel, user: Optional[dict]) -> bool:
|
|
||||||
return bool(user and user["status"] == "active" and can_access_channel(channel, user))
|
|
||||||
|
|
||||||
|
|
||||||
def team_payload(team, user: Optional[dict]) -> dict:
|
|
||||||
membership_role = team_member_role(team["id"], user["id"]) if user else None
|
|
||||||
return {
|
|
||||||
"id": team["id"],
|
|
||||||
"slug": team["slug"],
|
|
||||||
"name": team["name"],
|
|
||||||
"description": team["description"],
|
|
||||||
"membership_role": membership_role,
|
|
||||||
"can_read": can_access_team(team, user),
|
|
||||||
"can_manage": is_team_admin(team["id"], user),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def channel_payload(channel, user: Optional[dict]) -> dict:
|
|
||||||
return {
|
|
||||||
"id": channel["id"],
|
|
||||||
"team_id": channel["team_id"],
|
|
||||||
"team_slug": channel["team_slug"],
|
|
||||||
"team_name": channel["team_name"],
|
|
||||||
"legacy_room_id": channel["legacy_room_id"],
|
|
||||||
"slug": channel["slug"],
|
|
||||||
"name": channel["name"],
|
|
||||||
"description": channel["description"],
|
|
||||||
"can_read": can_access_channel(channel, user),
|
|
||||||
"can_write": can_write_channel(channel, user),
|
|
||||||
"can_manage": is_team_admin(channel["team_id"], user),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_dm_for_user(dm_id: int, user_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
dc.id,
|
|
||||||
dc.created_at
|
|
||||||
FROM direct_conversations AS dc
|
|
||||||
JOIN direct_conversation_members AS dcm
|
|
||||||
ON dcm.direct_conversation_id = dc.id
|
|
||||||
WHERE dc.id = ?
|
|
||||||
AND dcm.user_id = ?
|
|
||||||
""",
|
|
||||||
(dm_id, user_id),
|
|
||||||
).fetchone()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def get_dm_other_user(dm_id: int, user_id: int):
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username,
|
|
||||||
u.role,
|
|
||||||
up.display_name,
|
|
||||||
up.status_message,
|
|
||||||
us.name_color,
|
|
||||||
us.presence_mode
|
|
||||||
FROM direct_conversation_members AS dcm
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = dcm.user_id
|
|
||||||
LEFT JOIN user_profiles AS up
|
|
||||||
ON up.user_id = u.id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = u.id
|
|
||||||
WHERE dcm.direct_conversation_id = ?
|
|
||||||
AND dcm.user_id != ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(dm_id, user_id),
|
|
||||||
).fetchone()
|
|
||||||
return row
|
|
||||||
|
|
||||||
|
|
||||||
def validate_dm_access(dm_id: int, user: Optional[dict]) -> bool:
|
|
||||||
if not user:
|
|
||||||
return False
|
|
||||||
return bool(get_dm_for_user(dm_id, user["id"]))
|
|
||||||
|
|
||||||
|
|
||||||
def dm_payload(dm_id: int, user_id: int, resolve_name_color) -> dict:
|
|
||||||
dm = get_dm_for_user(dm_id, user_id)
|
|
||||||
other = get_dm_other_user(dm_id, user_id)
|
|
||||||
return {
|
|
||||||
"id": dm["id"],
|
|
||||||
"created_at": dm["created_at"],
|
|
||||||
"other_user": {
|
|
||||||
"id": other["id"],
|
|
||||||
"username": other["username"],
|
|
||||||
"display_name": other["display_name"] or other["username"],
|
|
||||||
"role": other["role"],
|
|
||||||
"name_color": other["name_color"],
|
|
||||||
"resolved_name_color": resolve_name_color(other["role"], other["name_color"]),
|
|
||||||
"status_message": other["status_message"] or "",
|
|
||||||
"presence_mode": other["presence_mode"] or "online",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def find_or_create_dm(user_a_id: int, user_b_id: int, now_iso_func) -> int:
|
|
||||||
low_id, high_id = sorted([int(user_a_id), int(user_b_id)])
|
|
||||||
with get_db() as db:
|
|
||||||
existing = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT dc.id
|
|
||||||
FROM direct_conversations AS dc
|
|
||||||
JOIN direct_conversation_members AS m1
|
|
||||||
ON m1.direct_conversation_id = dc.id AND m1.user_id = ?
|
|
||||||
JOIN direct_conversation_members AS m2
|
|
||||||
ON m2.direct_conversation_id = dc.id AND m2.user_id = ?
|
|
||||||
WHERE dc.member_pair_key = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(low_id, high_id, f"{low_id}:{high_id}"),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
return existing["id"]
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
existing = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id
|
|
||||||
FROM direct_conversations
|
|
||||||
WHERE member_pair_key = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(f"{low_id}:{high_id}",),
|
|
||||||
).fetchone()
|
|
||||||
if existing:
|
|
||||||
db.commit()
|
|
||||||
return existing["id"]
|
|
||||||
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO direct_conversations (
|
|
||||||
member_pair_key,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?)
|
|
||||||
""",
|
|
||||||
(f"{low_id}:{high_id}", now_iso_func()),
|
|
||||||
)
|
|
||||||
dm_id = cursor.lastrowid
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO direct_conversation_members (
|
|
||||||
direct_conversation_id,
|
|
||||||
user_id,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(dm_id, low_id, now_iso_func()),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO direct_conversation_members (
|
|
||||||
direct_conversation_id,
|
|
||||||
user_id,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(dm_id, high_id, now_iso_func()),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
return dm_id
|
|
||||||
|
|
||||||
|
|
||||||
def update_presence(
|
|
||||||
db_write_func,
|
|
||||||
user_id: int,
|
|
||||||
view_name: Optional[str] = None,
|
|
||||||
room_id: Optional[int] = None,
|
|
||||||
channel_id: Optional[int] = None,
|
|
||||||
direct_conversation_id: Optional[int] = None,
|
|
||||||
) -> None:
|
|
||||||
db_write_func(
|
|
||||||
"""
|
|
||||||
INSERT INTO presence_events (
|
|
||||||
user_id,
|
|
||||||
updated_at,
|
|
||||||
view_name,
|
|
||||||
room_id,
|
|
||||||
channel_id,
|
|
||||||
direct_conversation_id
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id)
|
|
||||||
DO UPDATE SET
|
|
||||||
updated_at = excluded.updated_at,
|
|
||||||
view_name = excluded.view_name,
|
|
||||||
room_id = excluded.room_id,
|
|
||||||
channel_id = excluded.channel_id,
|
|
||||||
direct_conversation_id = excluded.direct_conversation_id
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
user_id,
|
|
||||||
time.time(),
|
|
||||||
view_name,
|
|
||||||
room_id,
|
|
||||||
channel_id,
|
|
||||||
direct_conversation_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def room_presence_count(ttl_seconds: int, room_id: int) -> int:
|
|
||||||
cutoff = time.time() - ttl_seconds
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM presence_events
|
|
||||||
WHERE room_id = ?
|
|
||||||
AND updated_at >= ?
|
|
||||||
""",
|
|
||||||
(room_id, cutoff),
|
|
||||||
).fetchone()
|
|
||||||
return row["count"] if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
def channel_presence_count(ttl_seconds: int, channel_id: int) -> int:
|
|
||||||
cutoff = time.time() - ttl_seconds
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM presence_events
|
|
||||||
WHERE channel_id = ?
|
|
||||||
AND updated_at >= ?
|
|
||||||
""",
|
|
||||||
(channel_id, cutoff),
|
|
||||||
).fetchone()
|
|
||||||
return row["count"] if row else 0
|
|
||||||
|
|
||||||
|
|
||||||
def dm_presence_count(ttl_seconds: int, dm_id: int) -> int:
|
|
||||||
cutoff = time.time() - ttl_seconds
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM presence_events
|
|
||||||
WHERE direct_conversation_id = ?
|
|
||||||
AND updated_at >= ?
|
|
||||||
""",
|
|
||||||
(dm_id, cutoff),
|
|
||||||
).fetchone()
|
|
||||||
return row["count"] if row else 0
|
|
||||||
749
profiles.py
749
profiles.py
@ -1,749 +0,0 @@
|
|||||||
import html
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
from typing import Optional
|
|
||||||
import markdown
|
|
||||||
from html.parser import HTMLParser
|
|
||||||
from db import get_db
|
|
||||||
|
|
||||||
DEFAULT_ROLE_NAME_COLORS = {
|
|
||||||
"admin": "#ff7b7b",
|
|
||||||
"moderator": "#8ab4ff",
|
|
||||||
"user": "#f5f5f7",
|
|
||||||
}
|
|
||||||
_ALLOWED_TAGS = {
|
|
||||||
"b",
|
|
||||||
"strong",
|
|
||||||
"i",
|
|
||||||
"em",
|
|
||||||
"u",
|
|
||||||
"p",
|
|
||||||
"br",
|
|
||||||
"ul",
|
|
||||||
"ol",
|
|
||||||
"li",
|
|
||||||
"blockquote",
|
|
||||||
"code",
|
|
||||||
"pre",
|
|
||||||
"a",
|
|
||||||
"h1",
|
|
||||||
"h2",
|
|
||||||
"h3",
|
|
||||||
"h4",
|
|
||||||
"h5",
|
|
||||||
"h6",
|
|
||||||
}
|
|
||||||
|
|
||||||
_ALLOWED_ATTRS = {
|
|
||||||
"a": {"href", "title", "target", "rel"},
|
|
||||||
}
|
|
||||||
|
|
||||||
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_attr(value: str) -> str:
|
|
||||||
return html.escape(value, quote=True)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_html_fragment(raw: str) -> str:
|
|
||||||
class SafeHtmlParser(HTMLParser):
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(convert_charrefs=True)
|
|
||||||
self.parts = []
|
|
||||||
|
|
||||||
def handle_starttag(self, tag, attrs):
|
|
||||||
tag = tag.lower()
|
|
||||||
if tag not in _ALLOWED_TAGS:
|
|
||||||
return
|
|
||||||
|
|
||||||
allowed = _ALLOWED_ATTRS.get(tag, set())
|
|
||||||
clean_attrs = []
|
|
||||||
|
|
||||||
for key, value in attrs:
|
|
||||||
key = (key or "").lower()
|
|
||||||
value = value or ""
|
|
||||||
|
|
||||||
if key not in allowed:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if tag == "a" and key == "href":
|
|
||||||
lowered = value.strip().lower()
|
|
||||||
if not lowered.startswith(_ALLOWED_SCHEMES):
|
|
||||||
continue
|
|
||||||
|
|
||||||
clean_attrs.append((key, value))
|
|
||||||
|
|
||||||
if tag == "a":
|
|
||||||
attr_keys = {key for key, _ in clean_attrs}
|
|
||||||
if "target" not in attr_keys:
|
|
||||||
clean_attrs.append(("target", "_blank"))
|
|
||||||
if "rel" not in attr_keys:
|
|
||||||
clean_attrs.append(("rel", "noopener noreferrer"))
|
|
||||||
|
|
||||||
attr_text = "".join(
|
|
||||||
f' {key}="{_escape_attr(value)}"'
|
|
||||||
for key, value in clean_attrs
|
|
||||||
)
|
|
||||||
self.parts.append(f"<{tag}{attr_text}>")
|
|
||||||
|
|
||||||
def handle_endtag(self, tag):
|
|
||||||
tag = tag.lower()
|
|
||||||
if tag in _ALLOWED_TAGS and tag not in {"br"}:
|
|
||||||
self.parts.append(f"</{tag}>")
|
|
||||||
|
|
||||||
def handle_data(self, data):
|
|
||||||
self.parts.append(html.escape(data))
|
|
||||||
|
|
||||||
def handle_entityref(self, name):
|
|
||||||
self.parts.append(f"&{name};")
|
|
||||||
|
|
||||||
def handle_charref(self, name):
|
|
||||||
self.parts.append(f"&#{name};")
|
|
||||||
|
|
||||||
parser = SafeHtmlParser()
|
|
||||||
parser.feed(raw or "")
|
|
||||||
parser.close()
|
|
||||||
return "".join(parser.parts)
|
|
||||||
|
|
||||||
PROFILE_BIO_MODES = {"txt", "md", "html"}
|
|
||||||
THEME_MODES = {"dark", "light"}
|
|
||||||
PAGE_VISIBILITIES = {"team", "public"}
|
|
||||||
PAGE_TYPES = {"project", "document", "update", "resource"}
|
|
||||||
TEAM_MEMBER_ROLES = {"owner", "admin", "member"}
|
|
||||||
PROFILE_VISIBILITIES = {"public", "members", "private"}
|
|
||||||
LAST_ACTIVE_VISIBILITIES = {"everyone", "members", "nobody"}
|
|
||||||
PRESENCE_MODES = {"online", "silent", "focus", "dnd"}
|
|
||||||
DM_LAYOUT_MODES = {"cards", "compact"}
|
|
||||||
REACTION_EMOJI_RE = re.compile(r"^.{1,16}$")
|
|
||||||
HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$")
|
|
||||||
PAGE_LINK_RE = re.compile(r"\]\((?:/teams/[^/]+/pages/)?([a-z0-9-]{1,48})\)", re.IGNORECASE)
|
|
||||||
|
|
||||||
|
|
||||||
def valid_hex_color(value: object) -> bool:
|
|
||||||
return bool(isinstance(value, str) and HEX_COLOR_RE.fullmatch(value.strip()))
|
|
||||||
|
|
||||||
|
|
||||||
def normalized_hex_color(value: object) -> Optional[str]:
|
|
||||||
if not valid_hex_color(value):
|
|
||||||
return None
|
|
||||||
return str(value).strip().lower()
|
|
||||||
|
|
||||||
|
|
||||||
def role_default_name_color(role: Optional[str]) -> str:
|
|
||||||
return DEFAULT_ROLE_NAME_COLORS.get(str(role or "user"), DEFAULT_ROLE_NAME_COLORS["user"])
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_name_color(user_role: Optional[str], explicit_color: Optional[str]) -> str:
|
|
||||||
return normalized_hex_color(explicit_color) or role_default_name_color(user_role)
|
|
||||||
|
|
||||||
|
|
||||||
def render_markdown_to_safe_html(text: str) -> str:
|
|
||||||
rendered = markdown.markdown(
|
|
||||||
text or "",
|
|
||||||
extensions=[
|
|
||||||
"extra",
|
|
||||||
"sane_lists",
|
|
||||||
"nl2br",
|
|
||||||
"tables",
|
|
||||||
"fenced_code",
|
|
||||||
],
|
|
||||||
output_format="html5",
|
|
||||||
)
|
|
||||||
return sanitize_html_fragment(rendered)
|
|
||||||
|
|
||||||
|
|
||||||
def safe_profile_bio_html(bio: str, bio_mode: str) -> str:
|
|
||||||
mode = bio_mode if bio_mode in PROFILE_BIO_MODES else "txt"
|
|
||||||
if mode == "html":
|
|
||||||
return sanitize_html_fragment(bio or "")
|
|
||||||
if mode == "md":
|
|
||||||
return render_markdown_to_safe_html(bio or "")
|
|
||||||
return "<p>" + html.escape(bio or "").replace("\n", "<br>") + "</p>"
|
|
||||||
|
|
||||||
|
|
||||||
def extract_page_reference_slugs(markdown_text: str) -> list[str]:
|
|
||||||
text = str(markdown_text or "")
|
|
||||||
matches = PAGE_LINK_RE.findall(text)
|
|
||||||
seen = set()
|
|
||||||
slugs = []
|
|
||||||
for slug in matches:
|
|
||||||
normalized = slug.lower().strip()
|
|
||||||
if not normalized or normalized in seen:
|
|
||||||
continue
|
|
||||||
seen.add(normalized)
|
|
||||||
slugs.append(normalized)
|
|
||||||
return slugs
|
|
||||||
|
|
||||||
|
|
||||||
def detect_website_brand(website: str) -> dict:
|
|
||||||
value = str(website or "").strip().lower()
|
|
||||||
if not value:
|
|
||||||
return {"host": "", "brand": "web", "label": "website"}
|
|
||||||
|
|
||||||
host = value
|
|
||||||
host = host.replace("https://", "").replace("http://", "")
|
|
||||||
host = host.split("/", 1)[0]
|
|
||||||
host = host.removeprefix("www.")
|
|
||||||
|
|
||||||
mappings = [
|
|
||||||
("github.com", "github", "GitHub"),
|
|
||||||
("youtube.com", "youtube", "YouTube"),
|
|
||||||
("youtu.be", "youtube", "YouTube"),
|
|
||||||
("spotify.com", "spotify", "Spotify"),
|
|
||||||
("soundcloud.com", "soundcloud", "SoundCloud"),
|
|
||||||
("twitter.com", "x", "X"),
|
|
||||||
("x.com", "x", "X"),
|
|
||||||
]
|
|
||||||
|
|
||||||
for suffix, brand, label in mappings:
|
|
||||||
if host.endswith(suffix):
|
|
||||||
return {"host": host, "brand": brand, "label": label}
|
|
||||||
|
|
||||||
return {"host": host, "brand": "web", "label": "website"}
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_table_exists(table_name: str) -> bool:
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE type = 'table'
|
|
||||||
AND name = ?
|
|
||||||
""",
|
|
||||||
(table_name,),
|
|
||||||
).fetchone()
|
|
||||||
return bool(row)
|
|
||||||
|
|
||||||
|
|
||||||
def sqlite_column_exists(table_name: str, column_name: str) -> bool:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(f"PRAGMA table_info({table_name})").fetchall()
|
|
||||||
return any(row["name"] == column_name for row in rows)
|
|
||||||
|
|
||||||
|
|
||||||
def load_page_children(page_id: int) -> list[dict]:
|
|
||||||
if not sqlite_column_exists("pages", "parent_page_id"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
updated_at
|
|
||||||
FROM pages
|
|
||||||
WHERE parent_page_id = ?
|
|
||||||
AND is_active = 1
|
|
||||||
ORDER BY title COLLATE NOCASE, id ASC
|
|
||||||
""",
|
|
||||||
(page_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def load_page_backlinks(page_id: int, page_slug: str, team_id: int) -> list[dict]:
|
|
||||||
pattern_1 = f"%]({page_slug})%"
|
|
||||||
pattern_2 = f"%/pages/{page_slug})%"
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
title,
|
|
||||||
updated_at
|
|
||||||
FROM pages
|
|
||||||
WHERE team_id = ?
|
|
||||||
AND is_active = 1
|
|
||||||
AND id != ?
|
|
||||||
AND (body LIKE ? OR body LIKE ?)
|
|
||||||
ORDER BY updated_at DESC, id DESC
|
|
||||||
""",
|
|
||||||
(team_id, page_id, pattern_1, pattern_2),
|
|
||||||
).fetchall()
|
|
||||||
return [dict(row) for row in rows]
|
|
||||||
|
|
||||||
|
|
||||||
def load_page_comments(page_id: int) -> list[dict]:
|
|
||||||
if not sqlite_table_exists("page_comments"):
|
|
||||||
return []
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
pc.id,
|
|
||||||
pc.page_id,
|
|
||||||
pc.user_id,
|
|
||||||
pc.username,
|
|
||||||
pc.body,
|
|
||||||
pc.created_at,
|
|
||||||
pc.updated_at,
|
|
||||||
u.role AS user_role,
|
|
||||||
us.name_color AS user_name_color
|
|
||||||
FROM page_comments AS pc
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = pc.user_id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = u.id
|
|
||||||
WHERE pc.page_id = ?
|
|
||||||
AND pc.deleted_at IS NULL
|
|
||||||
ORDER BY pc.created_at ASC, pc.id ASC
|
|
||||||
""",
|
|
||||||
(page_id,),
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"page_id": row["page_id"],
|
|
||||||
"user_id": row["user_id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"body": row["body"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"updated_at": row["updated_at"],
|
|
||||||
"user_role": row["user_role"],
|
|
||||||
"user_name_color": resolve_name_color(row["user_role"], row["user_name_color"]),
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def reaction_summary_for_message(message_id: int, user: Optional[dict]) -> list[dict]:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
emoji,
|
|
||||||
COUNT(*) AS count,
|
|
||||||
MAX(CASE WHEN user_id = ? THEN 1 ELSE 0 END) AS reacted
|
|
||||||
FROM message_reactions
|
|
||||||
WHERE message_id = ?
|
|
||||||
GROUP BY emoji
|
|
||||||
ORDER BY count DESC, emoji ASC
|
|
||||||
""",
|
|
||||||
(user["id"] if user else -1, message_id),
|
|
||||||
).fetchall()
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"emoji": row["emoji"],
|
|
||||||
"count": row["count"],
|
|
||||||
"reacted": bool(row["reacted"]),
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def build_profile_privacy_context(viewer: Optional[dict], target_user_id: int) -> dict:
|
|
||||||
is_self = bool(viewer and viewer["id"] == target_user_id)
|
|
||||||
is_staff = bool(viewer and viewer["role"] in {"moderator", "admin"})
|
|
||||||
is_authenticated = bool(viewer)
|
|
||||||
return {
|
|
||||||
"is_self": is_self,
|
|
||||||
"is_staff": is_staff,
|
|
||||||
"is_authenticated": is_authenticated,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def can_view_profile_record(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
|
||||||
visibility = "public"
|
|
||||||
if settings_row and "profile_visibility" in settings_row.keys():
|
|
||||||
visibility = (settings_row["profile_visibility"] or "public").lower()
|
|
||||||
if visibility not in PROFILE_VISIBILITIES:
|
|
||||||
visibility = "public"
|
|
||||||
|
|
||||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
|
||||||
|
|
||||||
if visibility == "public":
|
|
||||||
return True
|
|
||||||
if visibility == "members":
|
|
||||||
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
|
||||||
if visibility == "private":
|
|
||||||
return ctx["is_self"] or ctx["is_staff"]
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def can_view_last_active(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
|
||||||
visibility = "everyone"
|
|
||||||
if settings_row and "last_active_visibility" in settings_row.keys():
|
|
||||||
visibility = (settings_row["last_active_visibility"] or "everyone").lower()
|
|
||||||
if visibility not in LAST_ACTIVE_VISIBILITIES:
|
|
||||||
visibility = "everyone"
|
|
||||||
|
|
||||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
|
||||||
|
|
||||||
if visibility == "everyone":
|
|
||||||
return True
|
|
||||||
if visibility == "members":
|
|
||||||
return ctx["is_authenticated"] or ctx["is_staff"] or ctx["is_self"]
|
|
||||||
if visibility == "nobody":
|
|
||||||
return ctx["is_self"] or ctx["is_staff"]
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def profile_presence_badge_visible(settings_row, viewer: Optional[dict], target_user_id: int) -> bool:
|
|
||||||
ctx = build_profile_privacy_context(viewer, target_user_id)
|
|
||||||
if ctx["is_self"] or ctx["is_staff"]:
|
|
||||||
return True
|
|
||||||
if settings_row and "show_presence_badge" in settings_row.keys():
|
|
||||||
return bool(settings_row["show_presence_badge"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def profile_payload(user_payload_func, user_row, profile_row, settings_row, viewer: Optional[dict]) -> dict:
|
|
||||||
is_self = bool(viewer and viewer["id"] == user_row["id"])
|
|
||||||
status_payload = user_payload_func(user_row, settings_row)
|
|
||||||
bio = profile_row["bio"] or ""
|
|
||||||
bio_mode = profile_row["bio_mode"] or "txt"
|
|
||||||
explicit_name_color = settings_row["name_color"] if settings_row else None
|
|
||||||
resolved = resolve_name_color(user_row["role"], explicit_name_color)
|
|
||||||
|
|
||||||
profile_visibility = settings_row["profile_visibility"] if settings_row and "profile_visibility" in settings_row.keys() else "public"
|
|
||||||
if profile_visibility not in PROFILE_VISIBILITIES:
|
|
||||||
profile_visibility = "public"
|
|
||||||
|
|
||||||
last_active_visibility = settings_row["last_active_visibility"] if settings_row and "last_active_visibility" in settings_row.keys() else "everyone"
|
|
||||||
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
|
|
||||||
last_active_visibility = "everyone"
|
|
||||||
|
|
||||||
presence_mode = settings_row["presence_mode"] if settings_row and "presence_mode" in settings_row.keys() else "online"
|
|
||||||
if presence_mode not in PRESENCE_MODES:
|
|
||||||
presence_mode = "online"
|
|
||||||
|
|
||||||
dm_layout_mode = settings_row["dm_layout_mode"] if settings_row and "dm_layout_mode" in settings_row.keys() else "cards"
|
|
||||||
if dm_layout_mode not in DM_LAYOUT_MODES:
|
|
||||||
dm_layout_mode = "cards"
|
|
||||||
|
|
||||||
show_presence_badge = profile_presence_badge_visible(settings_row, viewer, user_row["id"])
|
|
||||||
|
|
||||||
last_active_at = user_row["last_login_at"] if "last_login_at" in user_row.keys() else None
|
|
||||||
if not can_view_last_active(settings_row, viewer, user_row["id"]):
|
|
||||||
last_active_at = None
|
|
||||||
|
|
||||||
website = profile_row["website"] or ""
|
|
||||||
website_meta = detect_website_brand(website) if website else {"host": "", "brand": "web", "label": "website"}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": user_row["id"],
|
|
||||||
"username": user_row["username"],
|
|
||||||
"role": user_row["role"],
|
|
||||||
"status": status_payload["status"],
|
|
||||||
"presence_mode": presence_mode,
|
|
||||||
"created_at": user_row["created_at"],
|
|
||||||
"last_active_at": last_active_at,
|
|
||||||
"display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"],
|
|
||||||
"badge_text": (profile_row["badge_text"] or "").strip() if "badge_text" in profile_row.keys() else "",
|
|
||||||
"bio": bio,
|
|
||||||
"bio_mode": bio_mode,
|
|
||||||
"bio_html": safe_profile_bio_html(bio, bio_mode),
|
|
||||||
"website": website,
|
|
||||||
"website_meta": website_meta,
|
|
||||||
"location": profile_row["location"] or "",
|
|
||||||
"status_message": profile_row["status_message"] if "status_message" in profile_row.keys() else "",
|
|
||||||
"banner_attachment_id": profile_row["banner_attachment_id"] if "banner_attachment_id" in profile_row.keys() else None,
|
|
||||||
"banner_image_url": f"/api/attachments/{profile_row['banner_attachment_id']}/download" if ("banner_attachment_id" in profile_row.keys() and profile_row["banner_attachment_id"]) else "",
|
|
||||||
"updated_at": profile_row["updated_at"],
|
|
||||||
"is_self": is_self,
|
|
||||||
"name_color": explicit_name_color,
|
|
||||||
"resolved_name_color": resolved,
|
|
||||||
"profile_background_color": settings_row["profile_background_color"] if settings_row and "profile_background_color" in settings_row.keys() else None,
|
|
||||||
"profile_banner_color": settings_row["profile_banner_color"] if settings_row and "profile_banner_color" in settings_row.keys() else None,
|
|
||||||
"profile_visibility": profile_visibility,
|
|
||||||
"last_active_visibility": last_active_visibility,
|
|
||||||
"show_presence_badge": show_presence_badge,
|
|
||||||
"dm_layout_mode": dm_layout_mode,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def page_payload(page, user: Optional[dict], is_team_member_func, is_team_admin_func) -> dict:
|
|
||||||
can_read = bool(
|
|
||||||
page["visibility"] == "public"
|
|
||||||
or (
|
|
||||||
user
|
|
||||||
and (
|
|
||||||
user["role"] in {"moderator", "admin"}
|
|
||||||
or is_team_member_func(page["team_id"], user["id"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rendered_html = render_markdown_to_safe_html(page["body"] or "")
|
|
||||||
parent_page_id = page["parent_page_id"] if "parent_page_id" in page.keys() else None
|
|
||||||
parent_page_slug = page["parent_page_slug"] if "parent_page_slug" in page.keys() else None
|
|
||||||
parent_page_title = page["parent_page_title"] if "parent_page_title" in page.keys() else None
|
|
||||||
return {
|
|
||||||
"id": page["id"],
|
|
||||||
"team_id": page["team_id"],
|
|
||||||
"team_slug": page["team_slug"],
|
|
||||||
"team_name": page["team_name"],
|
|
||||||
"channel_id": page["channel_id"],
|
|
||||||
"channel_slug": page["channel_slug"],
|
|
||||||
"channel_name": page["channel_name"],
|
|
||||||
"parent_page_id": parent_page_id,
|
|
||||||
"parent_page_slug": parent_page_slug,
|
|
||||||
"parent_page_title": parent_page_title,
|
|
||||||
"slug": page["slug"],
|
|
||||||
"title": page["title"],
|
|
||||||
"body": page["body"],
|
|
||||||
"body_html": rendered_html,
|
|
||||||
"visibility": page["visibility"],
|
|
||||||
"page_type": page["page_type"],
|
|
||||||
"created_by_user_id": page["created_by_user_id"],
|
|
||||||
"updated_by_user_id": page["updated_by_user_id"],
|
|
||||||
"created_at": page["created_at"],
|
|
||||||
"updated_at": page["updated_at"],
|
|
||||||
"can_read": can_read,
|
|
||||||
"can_edit": bool(
|
|
||||||
user
|
|
||||||
and (
|
|
||||||
user["role"] in {"moderator", "admin"}
|
|
||||||
or is_team_admin_func(page["team_id"], user)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
"children": load_page_children(page["id"]),
|
|
||||||
"backlinks": load_page_backlinks(
|
|
||||||
page["id"],
|
|
||||||
page["slug"],
|
|
||||||
page["team_id"],
|
|
||||||
),
|
|
||||||
"references": extract_page_reference_slugs(page["body"] or ""),
|
|
||||||
"comments": load_page_comments(page["id"]),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_dms_for_user(user_id: int) -> list[dict]:
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
dc.id,
|
|
||||||
dc.created_at,
|
|
||||||
other.id AS other_user_id,
|
|
||||||
other.username AS other_username,
|
|
||||||
other.role AS other_role,
|
|
||||||
us.name_color AS other_name_color,
|
|
||||||
up.display_name AS other_display_name,
|
|
||||||
up.status_message AS other_status_message,
|
|
||||||
us.presence_mode AS other_presence_mode,
|
|
||||||
(
|
|
||||||
SELECT m.text
|
|
||||||
FROM messages AS m
|
|
||||||
WHERE m.direct_conversation_id = dc.id
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) AS last_message_text,
|
|
||||||
(
|
|
||||||
SELECT m.created_at
|
|
||||||
FROM messages AS m
|
|
||||||
WHERE m.direct_conversation_id = dc.id
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) AS last_message_created_at,
|
|
||||||
(
|
|
||||||
SELECT m.id
|
|
||||||
FROM messages AS m
|
|
||||||
WHERE m.direct_conversation_id = dc.id
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT 1
|
|
||||||
) AS last_message_id,
|
|
||||||
(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM messages AS m2
|
|
||||||
WHERE m2.direct_conversation_id = dc.id
|
|
||||||
AND m2.user_id != ?
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM message_receipts AS mr
|
|
||||||
WHERE mr.message_id = m2.id
|
|
||||||
AND mr.user_id = ?
|
|
||||||
)
|
|
||||||
) AS unread_count
|
|
||||||
FROM direct_conversations AS dc
|
|
||||||
JOIN direct_conversation_members AS self_member
|
|
||||||
ON self_member.direct_conversation_id = dc.id
|
|
||||||
JOIN direct_conversation_members AS other_member
|
|
||||||
ON other_member.direct_conversation_id = dc.id
|
|
||||||
AND other_member.user_id != self_member.user_id
|
|
||||||
JOIN users AS other
|
|
||||||
ON other.id = other_member.user_id
|
|
||||||
LEFT JOIN user_profiles AS up
|
|
||||||
ON up.user_id = other.id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = other.id
|
|
||||||
WHERE self_member.user_id = ?
|
|
||||||
ORDER BY COALESCE(last_message_id, 0) DESC, dc.id DESC
|
|
||||||
""",
|
|
||||||
(user_id, user_id, user_id),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
items = []
|
|
||||||
for row in rows:
|
|
||||||
items.append(
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"other_user": {
|
|
||||||
"id": row["other_user_id"],
|
|
||||||
"username": row["other_username"],
|
|
||||||
"display_name": row["other_display_name"] or row["other_username"],
|
|
||||||
"role": row["other_role"],
|
|
||||||
"name_color": row["other_name_color"],
|
|
||||||
"resolved_name_color": resolve_name_color(row["other_role"], row["other_name_color"]),
|
|
||||||
"status_message": row["other_status_message"] or "",
|
|
||||||
"presence_mode": row["other_presence_mode"] or "online",
|
|
||||||
},
|
|
||||||
"last_message_text": row["last_message_text"],
|
|
||||||
"last_message_created_at": row["last_message_created_at"],
|
|
||||||
"last_message_id": row["last_message_id"],
|
|
||||||
"unread_count": int(row["unread_count"] or 0),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def richer_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
|
||||||
with get_db() as db:
|
|
||||||
edited = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT created_at
|
|
||||||
FROM message_edits
|
|
||||||
WHERE message_id = ?
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(row["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
receipt_count = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM message_receipts
|
|
||||||
WHERE message_id = ?
|
|
||||||
""",
|
|
||||||
(row["id"],),
|
|
||||||
).fetchone()["count"]
|
|
||||||
|
|
||||||
seen_by_user = False
|
|
||||||
is_bookmarked = False
|
|
||||||
is_pinned = False
|
|
||||||
pin_row = None
|
|
||||||
|
|
||||||
bookmark_table_exists = bool(
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE type = 'table'
|
|
||||||
AND name = 'message_bookmarks'
|
|
||||||
"""
|
|
||||||
).fetchone()
|
|
||||||
)
|
|
||||||
pin_table_exists = bool(
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM sqlite_master
|
|
||||||
WHERE type = 'table'
|
|
||||||
AND name = 'message_pins'
|
|
||||||
"""
|
|
||||||
).fetchone()
|
|
||||||
)
|
|
||||||
|
|
||||||
if user:
|
|
||||||
seen_by_user = bool(
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM message_receipts
|
|
||||||
WHERE message_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(row["id"], user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
)
|
|
||||||
if bookmark_table_exists:
|
|
||||||
is_bookmarked = bool(
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM message_bookmarks
|
|
||||||
WHERE message_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(row["id"], user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
)
|
|
||||||
|
|
||||||
if pin_table_exists:
|
|
||||||
pin_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT pinned_at, pinned_by_user_id
|
|
||||||
FROM message_pins
|
|
||||||
WHERE message_id = ?
|
|
||||||
""",
|
|
||||||
(row["id"],),
|
|
||||||
).fetchone()
|
|
||||||
is_pinned = bool(pin_row)
|
|
||||||
|
|
||||||
primary_attachment = attachment_payload_legacy_from_message_select_row(row)
|
|
||||||
attachments = attachment_list_for_message(row["id"])
|
|
||||||
if primary_attachment and not any(item["id"] == primary_attachment["id"] for item in attachments):
|
|
||||||
attachments.insert(0, primary_attachment)
|
|
||||||
|
|
||||||
user_role = row["user_role"] if "user_role" in row.keys() else "user"
|
|
||||||
user_name_color = row["user_name_color"] if "user_name_color" in row.keys() else None
|
|
||||||
resolved_color = resolve_name_color(user_role, user_name_color)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": row["id"],
|
|
||||||
"room_id": row["room_id"],
|
|
||||||
"channel_id": row["channel_id"],
|
|
||||||
"direct_conversation_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
|
||||||
"user_id": row["user_id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"user_role": user_role,
|
|
||||||
"user_name_color": resolved_color,
|
|
||||||
"text": row["text"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"updated_at": edited["created_at"] if edited else None,
|
|
||||||
"is_edited": bool(edited),
|
|
||||||
"parent_message_id": row["parent_message_id"],
|
|
||||||
"thread_reply_count": row["thread_reply_count"],
|
|
||||||
"attachment": primary_attachment,
|
|
||||||
"attachments": attachments,
|
|
||||||
"read_receipt_count": receipt_count,
|
|
||||||
"seen_by_current_user": seen_by_user,
|
|
||||||
"reactions": reaction_summary_for_message(row["id"], user),
|
|
||||||
"is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]),
|
|
||||||
"is_pinned": is_pinned,
|
|
||||||
"pinned_at": pin_row["pinned_at"] if pin_row else None,
|
|
||||||
"pinned_by_user_id": pin_row["pinned_by_user_id"] if pin_row else None,
|
|
||||||
"is_bookmarked": is_bookmarked,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def feed_message_payload(row, user: Optional[dict], attachment_payload_legacy_from_message_select_row, attachment_list_for_message) -> dict:
|
|
||||||
payload = richer_message_payload(
|
|
||||||
row,
|
|
||||||
user,
|
|
||||||
attachment_payload_legacy_from_message_select_row,
|
|
||||||
attachment_list_for_message,
|
|
||||||
)
|
|
||||||
payload.update(
|
|
||||||
{
|
|
||||||
"room_slug": row["room_slug"] if "room_slug" in row.keys() else None,
|
|
||||||
"room_name": row["room_name"] if "room_name" in row.keys() else None,
|
|
||||||
"room_visibility": row["room_visibility"] if "room_visibility" in row.keys() else None,
|
|
||||||
"team_slug": row["team_slug"] if "team_slug" in row.keys() else None,
|
|
||||||
"team_name": row["team_name"] if "team_name" in row.keys() else None,
|
|
||||||
"channel_slug": row["channel_slug"] if "channel_slug" in row.keys() else None,
|
|
||||||
"channel_name": row["channel_name"] if "channel_name" in row.keys() else None,
|
|
||||||
"dm_id": row["direct_conversation_id"] if "direct_conversation_id" in row.keys() else None,
|
|
||||||
"is_mention": bool(
|
|
||||||
user and f"@{user['username'].lower()}" in payload["text"].lower()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return payload
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
from .attachments import register_attachment_routes
|
|
||||||
from .messages import register_message_routes
|
|
||||||
from .feed import register_feed_routes
|
|
||||||
from .presence import register_presence_routes
|
|
||||||
from .rooms import register_room_routes
|
|
||||||
from .teams import register_team_routes
|
|
||||||
from .profiles import register_profile_routes
|
|
||||||
from .dms import register_dm_routes
|
|
||||||
from .calls import register_call_routes
|
|
||||||
from .admin import register_admin_routes
|
|
||||||
|
|
||||||
|
|
||||||
def register_routes(app):
|
|
||||||
register_attachment_routes(app)
|
|
||||||
register_message_routes(app)
|
|
||||||
register_feed_routes(app)
|
|
||||||
register_presence_routes(app)
|
|
||||||
register_room_routes(app)
|
|
||||||
register_team_routes(app)
|
|
||||||
register_profile_routes(app)
|
|
||||||
register_dm_routes(app)
|
|
||||||
register_call_routes(app)
|
|
||||||
register_admin_routes(app)
|
|
||||||
268
routes/admin.py
268
routes/admin.py
@ -1,268 +0,0 @@
|
|||||||
from .common import (
|
|
||||||
ROLES,
|
|
||||||
STATUSES,
|
|
||||||
now_iso,
|
|
||||||
parse_iso,
|
|
||||||
utc_now,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
require_moderator,
|
|
||||||
require_admin,
|
|
||||||
audit,
|
|
||||||
hash_identifier,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
get_db,
|
|
||||||
db_write,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_admin_routes(app):
|
|
||||||
@app.route("/api/admin/users")
|
|
||||||
def admin_list_users():
|
|
||||||
moderator, error = require_moderator()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username,
|
|
||||||
u.role,
|
|
||||||
u.status,
|
|
||||||
u.restricted_until,
|
|
||||||
u.created_at,
|
|
||||||
u.last_login_at,
|
|
||||||
COUNT(m.id) AS message_count
|
|
||||||
FROM users AS u
|
|
||||||
LEFT JOIN messages AS m
|
|
||||||
ON m.user_id = u.id
|
|
||||||
GROUP BY u.id
|
|
||||||
ORDER BY
|
|
||||||
CASE u.role
|
|
||||||
WHEN 'admin' THEN 0
|
|
||||||
WHEN 'moderator' THEN 1
|
|
||||||
ELSE 2
|
|
||||||
END,
|
|
||||||
u.username COLLATE NOCASE
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"role": row["role"],
|
|
||||||
"status": row["status"],
|
|
||||||
"restricted_until": row["restricted_until"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"last_login_at": row["last_login_at"],
|
|
||||||
"message_count": row["message_count"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
],
|
|
||||||
"requested_by": moderator["username"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/admin/users/<int:user_id>", methods=["PATCH"])
|
|
||||||
def admin_update_user(user_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("admin")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
target = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user_id,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
return jsonify({"error": "User not found."}), 404
|
|
||||||
|
|
||||||
if target["id"] == admin["id"]:
|
|
||||||
return jsonify({"error": "You cannot modify your own account here."}), 400
|
|
||||||
|
|
||||||
role = data.get("role", target["role"])
|
|
||||||
status = data.get("status", target["status"])
|
|
||||||
restricted_until = data.get("restricted_until", target["restricted_until"])
|
|
||||||
|
|
||||||
if role not in ROLES:
|
|
||||||
return jsonify({"error": "Invalid role."}), 400
|
|
||||||
if status not in STATUSES:
|
|
||||||
return jsonify({"error": "Invalid account status."}), 400
|
|
||||||
|
|
||||||
if status == "restricted" and restricted_until:
|
|
||||||
parsed = parse_iso(restricted_until)
|
|
||||||
if not parsed or parsed <= utc_now():
|
|
||||||
return jsonify({"error": "restricted_until must be a future ISO timestamp."}), 400
|
|
||||||
elif status != "restricted":
|
|
||||||
restricted_until = None
|
|
||||||
|
|
||||||
if target["role"] == "admin" and role != "admin":
|
|
||||||
with get_db() as db:
|
|
||||||
count = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT COUNT(*) AS count
|
|
||||||
FROM users
|
|
||||||
WHERE role = 'admin'
|
|
||||||
AND status = 'active'
|
|
||||||
"""
|
|
||||||
).fetchone()["count"]
|
|
||||||
if count <= 1:
|
|
||||||
return jsonify({"error": "At least one active administrator is required."}), 400
|
|
||||||
|
|
||||||
is_admin_value = 1 if role == "admin" else 0
|
|
||||||
is_active = 1 if status in {"active", "restricted"} else 0
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE users
|
|
||||||
SET
|
|
||||||
role = ?,
|
|
||||||
status = ?,
|
|
||||||
restricted_until = ?,
|
|
||||||
is_admin = ?,
|
|
||||||
is_active = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(role, status, restricted_until, is_admin_value, is_active, user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
if status == "blacklisted":
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO blacklist_entries (
|
|
||||||
kind,
|
|
||||||
value_hash,
|
|
||||||
reason,
|
|
||||||
created_at,
|
|
||||||
created_by_user_id
|
|
||||||
) VALUES ('user', ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
hash_identifier(str(user_id)),
|
|
||||||
"Account blacklisted by administrator",
|
|
||||||
now_iso(),
|
|
||||||
admin["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if target["status"] == "blacklisted" and status != "blacklisted":
|
|
||||||
db_write(
|
|
||||||
"DELETE FROM blacklist_entries WHERE kind = 'user' AND value_hash = ?",
|
|
||||||
(hash_identifier(str(user_id)),),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"user_updated",
|
|
||||||
admin["id"],
|
|
||||||
"user",
|
|
||||||
str(user_id),
|
|
||||||
f"role={role};status={status};restricted_until={restricted_until}",
|
|
||||||
)
|
|
||||||
return jsonify({"status": "updated"})
|
|
||||||
|
|
||||||
@app.route("/api/admin/rooms")
|
|
||||||
def admin_rooms():
|
|
||||||
moderator, error = require_moderator()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_by_user_id,
|
|
||||||
is_active
|
|
||||||
FROM rooms
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY name COLLATE NOCASE
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"rooms": [
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"slug": row["slug"],
|
|
||||||
"name": row["name"],
|
|
||||||
"description": row["description"],
|
|
||||||
"visibility": row["visibility"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/admin/blacklist/ip", methods=["POST"])
|
|
||||||
def blacklist_ip():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
ip = str(data.get("ip", "")).strip()
|
|
||||||
reason = str(data.get("reason", "")).strip()[:300]
|
|
||||||
|
|
||||||
if not ip or len(ip) > 128:
|
|
||||||
return jsonify({"error": "A valid IP value is required."}), 400
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO blacklist_entries (
|
|
||||||
kind,
|
|
||||||
value_hash,
|
|
||||||
reason,
|
|
||||||
created_at,
|
|
||||||
created_by_user_id
|
|
||||||
) VALUES ('ip', ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
hash_identifier(ip),
|
|
||||||
reason or "IP blacklisted by administrator",
|
|
||||||
now_iso(),
|
|
||||||
admin["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("ip_blacklisted", admin["id"], "ip", hash_identifier(ip), reason)
|
|
||||||
return jsonify({"status": "blacklisted"})
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
from .common import (
|
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS,
|
|
||||||
ALLOWED_ATTACHMENT_MIME_TYPES,
|
|
||||||
MAX_ATTACHMENT_BYTES,
|
|
||||||
MAX_ATTACHMENT_NAME_LENGTH,
|
|
||||||
UPLOAD_DIR,
|
|
||||||
now_iso,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
audit,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
request,
|
|
||||||
db_write,
|
|
||||||
can_access_room,
|
|
||||||
can_access_channel,
|
|
||||||
get_channel_by_id,
|
|
||||||
)
|
|
||||||
from attachments import send_attachment_file
|
|
||||||
|
|
||||||
|
|
||||||
def register_attachment_routes(app):
|
|
||||||
@app.route("/api/attachments", methods=["POST"])
|
|
||||||
def upload_attachment():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("attachment")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
app.maybe_cleanup_orphaned_attachments()
|
|
||||||
|
|
||||||
uploaded = request.files.get("attachment")
|
|
||||||
if not uploaded or not uploaded.filename:
|
|
||||||
return jsonify({"error": "Choose a file to upload."}), 400
|
|
||||||
|
|
||||||
original_name = app.secure_filename(uploaded.filename)[:MAX_ATTACHMENT_NAME_LENGTH]
|
|
||||||
if not original_name or "." not in original_name:
|
|
||||||
return jsonify({"error": "Attachment must have an allowed file extension."}), 400
|
|
||||||
|
|
||||||
extension = original_name.rsplit(".", 1)[1].lower()
|
|
||||||
if extension not in ALLOWED_ATTACHMENT_EXTENSIONS:
|
|
||||||
return jsonify({"error": "This file type is not allowed."}), 400
|
|
||||||
|
|
||||||
declared_mime = (uploaded.mimetype or "").lower()
|
|
||||||
guessed_mime = app.mimetypes.guess_type(original_name)[0] or ""
|
|
||||||
|
|
||||||
if declared_mime and declared_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
|
|
||||||
return jsonify({"error": "This file MIME type is not allowed."}), 400
|
|
||||||
|
|
||||||
if guessed_mime and guessed_mime not in ALLOWED_ATTACHMENT_MIME_TYPES:
|
|
||||||
return jsonify({"error": "This file extension is not allowed."}), 400
|
|
||||||
|
|
||||||
content = uploaded.stream.read(MAX_ATTACHMENT_BYTES + 1)
|
|
||||||
if not content:
|
|
||||||
return jsonify({"error": "Attachment cannot be empty."}), 400
|
|
||||||
|
|
||||||
if len(content) > MAX_ATTACHMENT_BYTES:
|
|
||||||
return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400
|
|
||||||
|
|
||||||
stored_name = f"{app.secrets.token_urlsafe(24)}.{extension}"
|
|
||||||
destination = UPLOAD_DIR / stored_name
|
|
||||||
|
|
||||||
try:
|
|
||||||
destination.write_bytes(content)
|
|
||||||
app.os.chmod(destination, 0o600)
|
|
||||||
except OSError:
|
|
||||||
app.logger.exception("Could not save attachment.")
|
|
||||||
return jsonify({"error": "Could not save attachment."}), 500
|
|
||||||
|
|
||||||
mime_type = declared_mime or guessed_mime or "application/octet-stream"
|
|
||||||
|
|
||||||
try:
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO attachments (
|
|
||||||
uploader_user_id,
|
|
||||||
stored_name,
|
|
||||||
original_name,
|
|
||||||
mime_type,
|
|
||||||
size_bytes,
|
|
||||||
created_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(user["id"], stored_name, original_name, mime_type, len(content), now_iso()),
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
destination.unlink(missing_ok=True)
|
|
||||||
raise
|
|
||||||
|
|
||||||
audit("attachment_uploaded", user["id"], "attachment", str(cursor.lastrowid), original_name)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"attachment": {
|
|
||||||
"id": cursor.lastrowid,
|
|
||||||
"original_name": original_name,
|
|
||||||
"mime_type": mime_type,
|
|
||||||
"size_bytes": len(content),
|
|
||||||
"url": f"/api/attachments/{cursor.lastrowid}/download",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
), 201
|
|
||||||
|
|
||||||
@app.route("/api/attachments/<int:attachment_id>/download")
|
|
||||||
def download_attachment(attachment_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
return send_attachment_file(
|
|
||||||
UPLOAD_DIR,
|
|
||||||
attachment_id,
|
|
||||||
user,
|
|
||||||
can_access_room,
|
|
||||||
can_access_channel,
|
|
||||||
get_channel_by_id,
|
|
||||||
)
|
|
||||||
876
routes/calls.py
876
routes/calls.py
@ -1,876 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
CALLS_BASE,
|
|
||||||
CALL_HELPER_SHARED_SECRET,
|
|
||||||
CALL_TOKEN_TTL_SECONDS,
|
|
||||||
now_iso,
|
|
||||||
parse_iso,
|
|
||||||
utc_now,
|
|
||||||
csrf_valid,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
audit,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
request,
|
|
||||||
get_db,
|
|
||||||
db_write,
|
|
||||||
sign_call_helper_token,
|
|
||||||
normalize_username,
|
|
||||||
get_room_by_slug,
|
|
||||||
can_access_room,
|
|
||||||
get_channel_by_slug,
|
|
||||||
can_access_channel,
|
|
||||||
validate_dm_access,
|
|
||||||
get_dm_other_user,
|
|
||||||
build_room_helper_call_id,
|
|
||||||
build_channel_helper_call_id,
|
|
||||||
get_or_create_group_call_session,
|
|
||||||
group_call_session_payload,
|
|
||||||
get_group_call_session_by_id,
|
|
||||||
upsert_group_call_participant,
|
|
||||||
heartbeat_group_call_participant,
|
|
||||||
mark_group_call_participant_left,
|
|
||||||
list_group_call_participants,
|
|
||||||
maybe_end_group_call_if_empty,
|
|
||||||
create_call_session,
|
|
||||||
call_payload,
|
|
||||||
get_call_with_dm,
|
|
||||||
append_call_signal,
|
|
||||||
list_call_signals,
|
|
||||||
end_call,
|
|
||||||
can_access_group_call_session,
|
|
||||||
can_manage_group_call_session,
|
|
||||||
group_call_scope_payload,
|
|
||||||
group_call_permissions_payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_call_routes(app):
|
|
||||||
@app.route("/api/group-calls/token", methods=["POST"])
|
|
||||||
def api_group_call_token():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not CALL_HELPER_SHARED_SECRET:
|
|
||||||
return jsonify({"error": "Call helper is not configured."}), 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
scope_kind = str(data.get("scope_kind", "")).strip().lower()
|
|
||||||
helper_call_id = None
|
|
||||||
scope_id = None
|
|
||||||
scope_slug = None
|
|
||||||
room_value = None
|
|
||||||
|
|
||||||
if scope_kind == "room":
|
|
||||||
room_slug = str(data.get("room_slug", "")).strip()
|
|
||||||
room = get_room_by_slug(room_slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
if not can_access_room(room, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
helper_call_id = build_room_helper_call_id(room)
|
|
||||||
scope_id = room["id"]
|
|
||||||
scope_slug = room["slug"]
|
|
||||||
room_value = room["slug"]
|
|
||||||
|
|
||||||
elif scope_kind == "channel":
|
|
||||||
team_slug = str(data.get("team_slug", "")).strip()
|
|
||||||
channel_slug = str(data.get("channel_slug", "")).strip()
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
if not channel:
|
|
||||||
return jsonify({"error": "Channel not found."}), 404
|
|
||||||
if not can_access_channel(channel, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
helper_call_id = build_channel_helper_call_id(channel)
|
|
||||||
scope_id = channel["id"]
|
|
||||||
scope_slug = f"{channel['team_slug']}/{channel['slug']}"
|
|
||||||
room_value = scope_slug
|
|
||||||
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "scope_kind must be room or channel."}), 400
|
|
||||||
|
|
||||||
session_row = get_or_create_group_call_session(
|
|
||||||
scope_kind=scope_kind,
|
|
||||||
scope_id=scope_id,
|
|
||||||
scope_slug=scope_slug,
|
|
||||||
helper_call_id=helper_call_id,
|
|
||||||
created_by_user_id=user["id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
upsert_group_call_participant(session_row["id"], user["id"])
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": f"user-{user['id']}",
|
|
||||||
"username": user["username"],
|
|
||||||
"room": room_value,
|
|
||||||
"call_id": helper_call_id,
|
|
||||||
"role": user["role"],
|
|
||||||
"can_call": True,
|
|
||||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
|
||||||
}
|
|
||||||
|
|
||||||
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_token_issued",
|
|
||||||
user["id"],
|
|
||||||
"group_call_session",
|
|
||||||
str(session_row["id"]),
|
|
||||||
helper_call_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"token": token,
|
|
||||||
"calls_base": CALLS_BASE,
|
|
||||||
"call_id": helper_call_id,
|
|
||||||
"session": group_call_session_payload(session_row),
|
|
||||||
"participants": list_group_call_participants(session_row["id"]),
|
|
||||||
"permissions": group_call_permissions_payload(session_row, user),
|
|
||||||
"scope": group_call_scope_payload(session_row, user),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/invite", methods=["POST"])
|
|
||||||
def api_group_call_invite():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not CALL_HELPER_SHARED_SECRET:
|
|
||||||
return jsonify({"error": "Call helper is not configured."}), 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
session_id_raw = data.get("group_call_session_id")
|
|
||||||
invite_username = normalize_username(data.get("username"))
|
|
||||||
|
|
||||||
try:
|
|
||||||
session_id = int(session_id_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"error": "Invalid group_call_session_id."}), 400
|
|
||||||
|
|
||||||
if not invite_username:
|
|
||||||
return jsonify({"error": "username is required."}), 400
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row or session_row["status"] != "active":
|
|
||||||
return jsonify({"error": "Active group call not found."}), 404
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
is_active_participant = False
|
|
||||||
with get_db() as db:
|
|
||||||
is_active_participant = bool(
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
SELECT 1
|
|
||||||
FROM group_call_participants
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
AND left_at IS NULL
|
|
||||||
""",
|
|
||||||
(session_row["id"], user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
)
|
|
||||||
|
|
||||||
if not (is_active_participant or can_manage_group_call_session(session_row, user)):
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"error": "Only active participants or scope managers can invite users.",
|
|
||||||
"code": "forbidden",
|
|
||||||
}
|
|
||||||
), 403
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
target = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status
|
|
||||||
FROM users
|
|
||||||
WHERE username = ?
|
|
||||||
""",
|
|
||||||
(invite_username,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
return jsonify({"error": "User not found."}), 404
|
|
||||||
|
|
||||||
if int(target["id"]) == int(user["id"]):
|
|
||||||
return jsonify({"error": "You are already in the call."}), 400
|
|
||||||
|
|
||||||
if target["status"] in {"disabled", "blacklisted"}:
|
|
||||||
return jsonify({"error": "User is not available."}), 400
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": f"user-{target['id']}",
|
|
||||||
"username": target["username"],
|
|
||||||
"room": session_row["scope_slug"],
|
|
||||||
"call_id": session_row["helper_call_id"],
|
|
||||||
"role": target["role"],
|
|
||||||
"can_call": True,
|
|
||||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
|
||||||
}
|
|
||||||
|
|
||||||
helper_token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
|
||||||
expires_at = (utc_now() + app.timedelta(minutes=10)).isoformat()
|
|
||||||
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO group_call_invites (
|
|
||||||
group_call_session_id,
|
|
||||||
inviter_user_id,
|
|
||||||
invitee_user_id,
|
|
||||||
helper_token,
|
|
||||||
status,
|
|
||||||
created_at,
|
|
||||||
expires_at,
|
|
||||||
responded_at
|
|
||||||
) VALUES (?, ?, ?, ?, 'pending', ?, ?, NULL)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
session_row["id"],
|
|
||||||
user["id"],
|
|
||||||
target["id"],
|
|
||||||
helper_token,
|
|
||||||
now_iso(),
|
|
||||||
expires_at,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_invite_created",
|
|
||||||
user["id"],
|
|
||||||
"group_call_session",
|
|
||||||
str(session_row["id"]),
|
|
||||||
f"invitee={target['username']}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"invite": {
|
|
||||||
"id": cursor.lastrowid,
|
|
||||||
"invitee_username": target["username"],
|
|
||||||
"group_call_session_id": session_row["id"],
|
|
||||||
"status": "pending",
|
|
||||||
"expires_at": expires_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
), 201
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/invites")
|
|
||||||
def api_list_group_call_invites():
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
i.id,
|
|
||||||
i.group_call_session_id,
|
|
||||||
i.status,
|
|
||||||
i.created_at,
|
|
||||||
i.expires_at,
|
|
||||||
i.responded_at,
|
|
||||||
s.scope_kind,
|
|
||||||
s.scope_slug,
|
|
||||||
s.helper_call_id,
|
|
||||||
s.status AS session_status,
|
|
||||||
inviter.username AS inviter_username
|
|
||||||
FROM group_call_invites AS i
|
|
||||||
JOIN group_call_sessions AS s
|
|
||||||
ON s.id = i.group_call_session_id
|
|
||||||
JOIN users AS inviter
|
|
||||||
ON inviter.id = i.inviter_user_id
|
|
||||||
WHERE i.invitee_user_id = ?
|
|
||||||
AND i.status = 'pending'
|
|
||||||
AND i.expires_at > ?
|
|
||||||
AND s.status = 'active'
|
|
||||||
ORDER BY i.id DESC
|
|
||||||
""",
|
|
||||||
(user["id"], now_iso()),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"invites": [
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"group_call_session_id": row["group_call_session_id"],
|
|
||||||
"scope_kind": row["scope_kind"],
|
|
||||||
"scope_slug": row["scope_slug"],
|
|
||||||
"helper_call_id": row["helper_call_id"],
|
|
||||||
"inviter_username": row["inviter_username"],
|
|
||||||
"status": row["status"],
|
|
||||||
"created_at": row["created_at"],
|
|
||||||
"expires_at": row["expires_at"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/invites/<int:invite_id>/accept", methods=["POST"])
|
|
||||||
def api_accept_group_call_invite(invite_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
i.id,
|
|
||||||
i.group_call_session_id,
|
|
||||||
i.invitee_user_id,
|
|
||||||
i.helper_token,
|
|
||||||
i.status,
|
|
||||||
i.expires_at,
|
|
||||||
s.helper_call_id
|
|
||||||
FROM group_call_invites AS i
|
|
||||||
JOIN group_call_sessions AS s
|
|
||||||
ON s.id = i.group_call_session_id
|
|
||||||
WHERE i.id = ?
|
|
||||||
AND i.invitee_user_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(invite_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return jsonify({"error": "Invite not found."}), 404
|
|
||||||
|
|
||||||
if row["status"] != "pending" or parse_iso(row["expires_at"]) <= utc_now():
|
|
||||||
return jsonify({"error": "Invite expired or unavailable."}), 400
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_invites
|
|
||||||
SET
|
|
||||||
status = 'accepted',
|
|
||||||
responded_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), invite_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
upsert_group_call_participant(row["group_call_session_id"], user["id"])
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_invite_accepted",
|
|
||||||
user["id"],
|
|
||||||
"group_call_invite",
|
|
||||||
str(invite_id),
|
|
||||||
row["helper_call_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(row["group_call_session_id"])
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"token": row["helper_token"],
|
|
||||||
"calls_base": CALLS_BASE,
|
|
||||||
"call_id": row["helper_call_id"],
|
|
||||||
"session": group_call_session_payload(session_row),
|
|
||||||
"participants": list_group_call_participants(row["group_call_session_id"]),
|
|
||||||
"permissions": group_call_permissions_payload(session_row, user),
|
|
||||||
"scope": group_call_scope_payload(session_row, user),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/invites/<int:invite_id>/dismiss", methods=["POST"])
|
|
||||||
def api_dismiss_group_call_invite(invite_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
group_call_session_id
|
|
||||||
FROM group_call_invites
|
|
||||||
WHERE id = ?
|
|
||||||
AND invitee_user_id = ?
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(invite_id, user["id"]),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not row:
|
|
||||||
return jsonify({"error": "Invite not found."}), 404
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_invites
|
|
||||||
SET
|
|
||||||
status = 'dismissed',
|
|
||||||
responded_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
AND invitee_user_id = ?
|
|
||||||
AND status = 'pending'
|
|
||||||
""",
|
|
||||||
(now_iso(), invite_id, user["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_invite_dismissed",
|
|
||||||
user["id"],
|
|
||||||
"group_call_invite",
|
|
||||||
str(invite_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "dismissed",
|
|
||||||
"group_call_session_id": row["group_call_session_id"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>")
|
|
||||||
def api_get_group_call_session(session_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"session": group_call_session_payload(session_row),
|
|
||||||
"scope": group_call_scope_payload(session_row, user),
|
|
||||||
"participants": list_group_call_participants(session_id),
|
|
||||||
"permissions": group_call_permissions_payload(session_row, user),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>/participants")
|
|
||||||
def api_get_group_call_participants(session_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"session_id": session_id,
|
|
||||||
"participants": list_group_call_participants(session_id),
|
|
||||||
"permissions": group_call_permissions_payload(session_row, user),
|
|
||||||
"scope": group_call_scope_payload(session_row, user),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>/join", methods=["POST"])
|
|
||||||
def api_join_group_call_session(session_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if session_row["status"] != "active":
|
|
||||||
return jsonify({"error": "Group call session is not active."}), 400
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
upsert_group_call_participant(session_id, user["id"])
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_session_joined",
|
|
||||||
user["id"],
|
|
||||||
"group_call_session",
|
|
||||||
str(session_id),
|
|
||||||
session_row["helper_call_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "joined",
|
|
||||||
"session": group_call_session_payload(session_row),
|
|
||||||
"participants": list_group_call_participants(session_id),
|
|
||||||
"permissions": group_call_permissions_payload(session_row, user),
|
|
||||||
"scope": group_call_scope_payload(session_row, user),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>/heartbeat", methods=["POST"])
|
|
||||||
def api_heartbeat_group_call_session(session_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if session_row["status"] != "active":
|
|
||||||
return jsonify({"error": "Group call session is not active."}), 400
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
heartbeat_group_call_participant(session_id, user["id"])
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "ok",
|
|
||||||
"session": group_call_session_payload(session_row),
|
|
||||||
"participants": list_group_call_participants(session_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>/leave", methods=["POST"])
|
|
||||||
def api_leave_group_call_session(session_id: int):
|
|
||||||
"""
|
|
||||||
keep csrf behavior for normal requests, but allow same-origin unload-style cleanup
|
|
||||||
where the frontend keepalive/beacon path may not be able to send the custom header.
|
|
||||||
"""
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not csrf_valid():
|
|
||||||
origin = request.headers.get("Origin", "")
|
|
||||||
sec_fetch_site = request.headers.get("Sec-Fetch-Site", "")
|
|
||||||
same_origin_hint = (not origin) or origin.rstrip("/") == request.host_url.rstrip("/") or sec_fetch_site == "same-origin"
|
|
||||||
if not same_origin_hint:
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if not can_access_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
mark_group_call_participant_left(session_id, user["id"])
|
|
||||||
maybe_end_group_call_if_empty(session_id)
|
|
||||||
|
|
||||||
refreshed = get_group_call_session_by_id(session_id)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_session_left",
|
|
||||||
user["id"],
|
|
||||||
"group_call_session",
|
|
||||||
str(session_id),
|
|
||||||
session_row["helper_call_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "left",
|
|
||||||
"session": group_call_session_payload(refreshed) if refreshed else None,
|
|
||||||
"participants": list_group_call_participants(session_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/group-calls/<int:session_id>/end", methods=["POST"])
|
|
||||||
def api_end_group_call_session(session_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
session_row = get_group_call_session_by_id(session_id)
|
|
||||||
if not session_row:
|
|
||||||
return jsonify({"error": "Group call session not found."}), 404
|
|
||||||
|
|
||||||
if not can_manage_group_call_session(session_row, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_sessions
|
|
||||||
SET
|
|
||||||
status = 'ended',
|
|
||||||
ended_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_participants
|
|
||||||
SET
|
|
||||||
updated_at = ?,
|
|
||||||
left_at = COALESCE(left_at, ?)
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND left_at IS NULL
|
|
||||||
""",
|
|
||||||
(now_iso(), now_iso(), session_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE group_call_invites
|
|
||||||
SET
|
|
||||||
status = 'dismissed',
|
|
||||||
responded_at = COALESCE(responded_at, ?)
|
|
||||||
WHERE group_call_session_id = ?
|
|
||||||
AND status = 'pending'
|
|
||||||
""",
|
|
||||||
(now_iso(), session_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
refreshed = get_group_call_session_by_id(session_id)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"group_call_session_ended",
|
|
||||||
user["id"],
|
|
||||||
"group_call_session",
|
|
||||||
str(session_id),
|
|
||||||
session_row["helper_call_id"],
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "ended",
|
|
||||||
"session": group_call_session_payload(refreshed) if refreshed else None,
|
|
||||||
"participants": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/call-token", methods=["POST"])
|
|
||||||
def api_call_token():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not CALL_HELPER_SHARED_SECRET:
|
|
||||||
return jsonify({"error": "Call helper is not configured."}), 503
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
room_slug = str(data.get("room", "")).strip()
|
|
||||||
dm_id_raw = data.get("dm_id")
|
|
||||||
|
|
||||||
call_id = None
|
|
||||||
room_value = None
|
|
||||||
|
|
||||||
if dm_id_raw is not None:
|
|
||||||
try:
|
|
||||||
dm_id = int(dm_id_raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"error": "Invalid dm_id."}), 400
|
|
||||||
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
peer = get_dm_other_user(dm_id, user["id"])
|
|
||||||
if not peer:
|
|
||||||
return jsonify({"error": "Direct-message peer not found."}), 404
|
|
||||||
|
|
||||||
call_id = f"dm-{dm_id}"
|
|
||||||
room_value = call_id
|
|
||||||
|
|
||||||
elif room_slug:
|
|
||||||
room = get_room_by_slug(room_slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
if not can_access_room(room, user):
|
|
||||||
return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
call_id = f"room-{room['slug']}"
|
|
||||||
room_value = room["slug"]
|
|
||||||
|
|
||||||
else:
|
|
||||||
return jsonify({"error": "room or dm_id is required."}), 400
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"sub": f"user-{user['id']}",
|
|
||||||
"username": user["username"],
|
|
||||||
"room": room_value,
|
|
||||||
"call_id": call_id,
|
|
||||||
"role": user["role"],
|
|
||||||
"can_call": True,
|
|
||||||
"exp": int(time.time()) + CALL_TOKEN_TTL_SECONDS,
|
|
||||||
}
|
|
||||||
|
|
||||||
token = sign_call_helper_token(payload, CALL_HELPER_SHARED_SECRET)
|
|
||||||
|
|
||||||
audit(
|
|
||||||
"call_helper_token_issued",
|
|
||||||
user["id"],
|
|
||||||
"call",
|
|
||||||
call_id,
|
|
||||||
f"room={room_value}",
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"token": token,
|
|
||||||
"calls_base": CALLS_BASE,
|
|
||||||
"call_id": call_id,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/calls", methods=["POST"])
|
|
||||||
def api_start_dm_call(dm_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
call_id = create_call_session(dm_id, user["id"])
|
|
||||||
audit("call_started", user["id"], "call", str(call_id), str(dm_id))
|
|
||||||
return jsonify({"call": call_payload(call_id)}), 201
|
|
||||||
|
|
||||||
@app.route("/api/calls/<int:call_id>")
|
|
||||||
def api_get_call(call_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
call = get_call_with_dm(call_id)
|
|
||||||
if not call:
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
return jsonify({"call": call_payload(call_id)})
|
|
||||||
|
|
||||||
@app.route("/api/calls/<int:call_id>/signal", methods=["POST"])
|
|
||||||
def api_post_call_signal(call_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
call = get_call_with_dm(call_id)
|
|
||||||
if not call:
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
if call["status"] != "active":
|
|
||||||
return jsonify({"error": "Call is not active."}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
signal_type = str(data.get("signal_type", "")).strip()[:40]
|
|
||||||
payload = data.get("payload", {})
|
|
||||||
|
|
||||||
if not signal_type:
|
|
||||||
return jsonify({"error": "signal_type is required."}), 400
|
|
||||||
if not isinstance(payload, dict):
|
|
||||||
return jsonify({"error": "payload must be an object."}), 400
|
|
||||||
|
|
||||||
signal_id = append_call_signal(call_id, user["id"], signal_type, payload)
|
|
||||||
return jsonify({"status": "ok", "signal_id": signal_id}), 201
|
|
||||||
|
|
||||||
@app.route("/api/calls/<int:call_id>/signals")
|
|
||||||
def api_get_call_signals(call_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
call = get_call_with_dm(call_id)
|
|
||||||
if not call:
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
after_raw = str(request.args.get("after_id", "")).strip()
|
|
||||||
after_id = 0
|
|
||||||
if after_raw:
|
|
||||||
try:
|
|
||||||
after_id = max(0, int(after_raw))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
|
||||||
|
|
||||||
signals = list_call_signals(call_id, after_id)
|
|
||||||
latest_id = signals[-1]["id"] if signals else after_id
|
|
||||||
return jsonify({"signals": signals, "latest_id": latest_id})
|
|
||||||
|
|
||||||
@app.route("/api/calls/<int:call_id>/end", methods=["POST"])
|
|
||||||
def api_end_call(call_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
call = get_call_with_dm(call_id)
|
|
||||||
if not call:
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
if not validate_dm_access(call["direct_conversation_id"], user):
|
|
||||||
return jsonify({"error": "Call not found."}), 404
|
|
||||||
|
|
||||||
end_call(call_id)
|
|
||||||
audit("call_ended", user["id"], "call", str(call_id))
|
|
||||||
return jsonify({"status": "ended", "call": call_payload(call_id)})
|
|
||||||
413
routes/common.py
413
routes/common.py
@ -1,413 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from flask import jsonify, request
|
|
||||||
|
|
||||||
from config import (
|
|
||||||
CALLS_BASE,
|
|
||||||
CALL_HELPER_SHARED_SECRET,
|
|
||||||
CALL_TOKEN_TTL_SECONDS,
|
|
||||||
CONSTANTS,
|
|
||||||
PRIVATE_ACCESS_KEY,
|
|
||||||
UPLOAD_DIR,
|
|
||||||
now_iso,
|
|
||||||
parse_iso,
|
|
||||||
utc_now,
|
|
||||||
)
|
|
||||||
from db import get_db, db_write
|
|
||||||
from auth import (
|
|
||||||
safe_json,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
current_user,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
require_moderator,
|
|
||||||
require_admin,
|
|
||||||
normalize_username,
|
|
||||||
audit,
|
|
||||||
hash_identifier,
|
|
||||||
sign_call_helper_token,
|
|
||||||
user_payload,
|
|
||||||
)
|
|
||||||
from models import (
|
|
||||||
ROOM_SLUG_RE,
|
|
||||||
MESSAGE_SELECT,
|
|
||||||
team_page_select,
|
|
||||||
team_slug_from_name,
|
|
||||||
page_slug_from_title,
|
|
||||||
room_by_id,
|
|
||||||
get_room_by_slug,
|
|
||||||
can_access_room,
|
|
||||||
can_write_room,
|
|
||||||
can_manage_room,
|
|
||||||
room_payload,
|
|
||||||
get_team_by_slug,
|
|
||||||
get_channel_by_id,
|
|
||||||
get_channel_by_slug,
|
|
||||||
get_channel_by_legacy_room_id,
|
|
||||||
team_member_role,
|
|
||||||
is_team_member,
|
|
||||||
is_team_admin,
|
|
||||||
can_edit_team,
|
|
||||||
can_access_team,
|
|
||||||
can_access_channel,
|
|
||||||
can_write_channel,
|
|
||||||
team_payload,
|
|
||||||
channel_payload,
|
|
||||||
get_dm_for_user,
|
|
||||||
get_dm_other_user,
|
|
||||||
validate_dm_access,
|
|
||||||
dm_payload,
|
|
||||||
find_or_create_dm,
|
|
||||||
update_presence,
|
|
||||||
room_presence_count,
|
|
||||||
channel_presence_count,
|
|
||||||
dm_presence_count,
|
|
||||||
)
|
|
||||||
from attachments import (
|
|
||||||
attachment_list_for_message,
|
|
||||||
attachment_payload_legacy_from_message_select_row,
|
|
||||||
resolve_owned_attachment,
|
|
||||||
resolve_owned_attachment_ids,
|
|
||||||
)
|
|
||||||
from profiles import (
|
|
||||||
DM_LAYOUT_MODES,
|
|
||||||
LAST_ACTIVE_VISIBILITIES,
|
|
||||||
PAGE_TYPES,
|
|
||||||
PAGE_VISIBILITIES,
|
|
||||||
PRESENCE_MODES,
|
|
||||||
PROFILE_BIO_MODES,
|
|
||||||
PROFILE_VISIBILITIES,
|
|
||||||
REACTION_EMOJI_RE,
|
|
||||||
TEAM_MEMBER_ROLES,
|
|
||||||
THEME_MODES,
|
|
||||||
normalized_hex_color,
|
|
||||||
valid_hex_color,
|
|
||||||
resolve_name_color,
|
|
||||||
page_payload,
|
|
||||||
profile_payload,
|
|
||||||
reaction_summary_for_message,
|
|
||||||
list_dms_for_user,
|
|
||||||
richer_message_payload,
|
|
||||||
feed_message_payload,
|
|
||||||
can_view_profile_record,
|
|
||||||
)
|
|
||||||
from calls import (
|
|
||||||
append_call_signal,
|
|
||||||
call_payload,
|
|
||||||
create_call_session,
|
|
||||||
end_call,
|
|
||||||
get_call_with_dm,
|
|
||||||
list_call_signals,
|
|
||||||
build_room_helper_call_id,
|
|
||||||
build_channel_helper_call_id,
|
|
||||||
get_or_create_group_call_session,
|
|
||||||
group_call_session_payload,
|
|
||||||
get_group_call_session_by_id,
|
|
||||||
upsert_group_call_participant,
|
|
||||||
heartbeat_group_call_participant,
|
|
||||||
mark_group_call_participant_left,
|
|
||||||
list_group_call_participants,
|
|
||||||
maybe_end_group_call_if_empty,
|
|
||||||
)
|
|
||||||
|
|
||||||
MAX_MESSAGE_LENGTH = CONSTANTS["MAX_MESSAGE_LENGTH"]
|
|
||||||
MAX_HISTORY_MESSAGES = CONSTANTS["MAX_HISTORY_MESSAGES"]
|
|
||||||
MAX_INCREMENTAL_MESSAGES = CONSTANTS["MAX_INCREMENTAL_MESSAGES"]
|
|
||||||
MAX_FEED_MESSAGES = CONSTANTS["MAX_FEED_MESSAGES"]
|
|
||||||
MAX_THREAD_REPLIES = CONSTANTS["MAX_THREAD_REPLIES"]
|
|
||||||
MAX_ATTACHMENT_BYTES = CONSTANTS["MAX_ATTACHMENT_BYTES"]
|
|
||||||
MAX_ATTACHMENT_NAME_LENGTH = CONSTANTS["MAX_ATTACHMENT_NAME_LENGTH"]
|
|
||||||
ALLOWED_ATTACHMENT_EXTENSIONS = CONSTANTS["ALLOWED_ATTACHMENT_EXTENSIONS"]
|
|
||||||
ALLOWED_ATTACHMENT_MIME_TYPES = CONSTANTS["ALLOWED_ATTACHMENT_MIME_TYPES"]
|
|
||||||
INVITE_TTL_HOURS = CONSTANTS["INVITE_TTL_HOURS"]
|
|
||||||
TEAM_INVITE_TTL_HOURS = CONSTANTS["TEAM_INVITE_TTL_HOURS"]
|
|
||||||
TYPING_TTL_SECONDS = CONSTANTS["TYPING_TTL_SECONDS"]
|
|
||||||
PRESENCE_TTL_SECONDS = CONSTANTS["PRESENCE_TTL_SECONDS"]
|
|
||||||
MAX_SEARCH_QUERY_LENGTH = CONSTANTS["MAX_SEARCH_QUERY_LENGTH"]
|
|
||||||
MAX_SEARCH_RESULTS = CONSTANTS["MAX_SEARCH_RESULTS"]
|
|
||||||
READ_RECEIPT_BATCH_LIMIT = CONSTANTS["READ_RECEIPT_BATCH_LIMIT"]
|
|
||||||
MAX_MESSAGE_EDIT_WINDOW_SECONDS = CONSTANTS["MAX_MESSAGE_EDIT_WINDOW_SECONDS"]
|
|
||||||
MAX_DISPLAY_NAME_LENGTH = CONSTANTS["MAX_DISPLAY_NAME_LENGTH"]
|
|
||||||
MAX_PROFILE_BIO_LENGTH = CONSTANTS["MAX_PROFILE_BIO_LENGTH"]
|
|
||||||
MAX_LOCATION_LENGTH = CONSTANTS["MAX_LOCATION_LENGTH"]
|
|
||||||
MAX_WEBSITE_LENGTH = CONSTANTS["MAX_WEBSITE_LENGTH"]
|
|
||||||
MAX_CUSTOM_CSS_LENGTH = CONSTANTS["MAX_CUSTOM_CSS_LENGTH"]
|
|
||||||
MAX_PROFILE_BADGE_LENGTH = CONSTANTS["MAX_PROFILE_BADGE_LENGTH"]
|
|
||||||
MAX_OWNED_TEAMS_PER_USER = CONSTANTS["MAX_OWNED_TEAMS_PER_USER"]
|
|
||||||
GROUP_CALL_PARTICIPANT_STALE_SECONDS = CONSTANTS["GROUP_CALL_PARTICIPANT_STALE_SECONDS"]
|
|
||||||
ROOM_VISIBILITIES = CONSTANTS["ROOM_VISIBILITIES"]
|
|
||||||
ROLES = CONSTANTS["ROLES"]
|
|
||||||
STATUSES = CONSTANTS["STATUSES"]
|
|
||||||
|
|
||||||
|
|
||||||
def private_access_key_valid(candidate: object) -> bool:
|
|
||||||
configured = (PRIVATE_ACCESS_KEY or "").strip()
|
|
||||||
if not configured:
|
|
||||||
return True
|
|
||||||
if not isinstance(candidate, str):
|
|
||||||
return False
|
|
||||||
return candidate.strip() == configured
|
|
||||||
|
|
||||||
|
|
||||||
def build_message_payload(row, user):
|
|
||||||
return richer_message_payload(
|
|
||||||
row,
|
|
||||||
user,
|
|
||||||
attachment_payload_legacy_from_message_select_row,
|
|
||||||
attachment_list_for_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_feed_payload(row, user):
|
|
||||||
return feed_message_payload(
|
|
||||||
row,
|
|
||||||
user,
|
|
||||||
attachment_payload_legacy_from_message_select_row,
|
|
||||||
attachment_list_for_message,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_local_stub_share_url(kind: str, object_id: int) -> str:
|
|
||||||
return f"/shared/{kind}/{object_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def require_csrf():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_json_request():
|
|
||||||
try:
|
|
||||||
return safe_json(), None
|
|
||||||
except ValueError as error:
|
|
||||||
return None, (jsonify({"error": str(error)}), 400)
|
|
||||||
|
|
||||||
|
|
||||||
def require_dm_access(dm_id: int, user):
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return None, (jsonify({"error": "DM not found."}), 404)
|
|
||||||
return dm_id, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_room_access(
|
|
||||||
slug: str,
|
|
||||||
user,
|
|
||||||
login_message="Login required.",
|
|
||||||
forbidden_message="You do not have access to this room.",
|
|
||||||
):
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return None, (jsonify({"error": "Room not found."}), 404)
|
|
||||||
|
|
||||||
if not can_access_room(room, user):
|
|
||||||
if not user:
|
|
||||||
return None, (jsonify({"error": login_message, "code": "login_required"}), 401)
|
|
||||||
return None, (jsonify({"error": forbidden_message, "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return room, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_room_write(slug: str, user):
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return None, (jsonify({"error": "Room not found."}), 404)
|
|
||||||
|
|
||||||
if not can_write_room(room, user):
|
|
||||||
return None, (jsonify({"error": "You cannot post in this room.", "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return room, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_channel_access(team_slug: str, channel_slug: str, user):
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
if not channel:
|
|
||||||
return None, (jsonify({"error": "Channel not found."}), 404)
|
|
||||||
|
|
||||||
if not can_access_channel(channel, user):
|
|
||||||
if not user:
|
|
||||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
|
||||||
return None, (jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return channel, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_channel_write(team_slug: str, channel_slug: str, user):
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
if not channel:
|
|
||||||
return None, (jsonify({"error": "Channel not found."}), 404)
|
|
||||||
|
|
||||||
if not can_write_channel(channel, user):
|
|
||||||
return None, (jsonify({"error": "You cannot post in this channel.", "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return channel, None
|
|
||||||
|
|
||||||
|
|
||||||
def user_can_access_message_scope(message_row, user) -> bool:
|
|
||||||
if not message_row:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if message_row["direct_conversation_id"]:
|
|
||||||
return bool(user and validate_dm_access(message_row["direct_conversation_id"], user))
|
|
||||||
|
|
||||||
if message_row["channel_id"]:
|
|
||||||
channel = get_channel_by_id(message_row["channel_id"])
|
|
||||||
return bool(channel and can_access_channel(channel, user))
|
|
||||||
|
|
||||||
if message_row["room_id"]:
|
|
||||||
room = room_by_id(message_row["room_id"])
|
|
||||||
return bool(room and can_access_room(room, user))
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_message_attachment_inputs(data: dict, user_id: int):
|
|
||||||
attachment_ids, attachment_error = resolve_owned_attachment_ids(
|
|
||||||
data.get("attachment_ids", []),
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
if attachment_error:
|
|
||||||
return None, None, attachment_error
|
|
||||||
|
|
||||||
if not attachment_ids and data.get("attachment_id") is not None:
|
|
||||||
single_attachment_id, single_attachment_error = resolve_owned_attachment(
|
|
||||||
data.get("attachment_id"),
|
|
||||||
user_id,
|
|
||||||
)
|
|
||||||
if single_attachment_error:
|
|
||||||
return None, None, single_attachment_error
|
|
||||||
attachment_ids = [single_attachment_id] if single_attachment_id else []
|
|
||||||
|
|
||||||
attachment_id = attachment_ids[0] if attachment_ids else None
|
|
||||||
return attachment_ids, attachment_id, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_team_admin(team_slug: str, user):
|
|
||||||
team = get_team_by_slug(team_slug)
|
|
||||||
if not team:
|
|
||||||
return None, (jsonify({"error": "Team not found."}), 404)
|
|
||||||
|
|
||||||
if not is_team_admin(team["id"], user):
|
|
||||||
return None, (jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return team, None
|
|
||||||
|
|
||||||
|
|
||||||
def require_team_access(team_slug: str, user):
|
|
||||||
team = get_team_by_slug(team_slug)
|
|
||||||
if not team:
|
|
||||||
return None, (jsonify({"error": "Team not found."}), 404)
|
|
||||||
|
|
||||||
if not can_access_team(team, user):
|
|
||||||
if not user:
|
|
||||||
return None, (jsonify({"error": "Login required.", "code": "login_required"}), 401)
|
|
||||||
return None, (jsonify({"error": "You do not have access to this team.", "code": "forbidden"}), 403)
|
|
||||||
|
|
||||||
return team, None
|
|
||||||
|
|
||||||
|
|
||||||
def load_active_page(team_slug: str, page_slug: str):
|
|
||||||
with get_db() as db:
|
|
||||||
page = db.execute(
|
|
||||||
f"""
|
|
||||||
{team_page_select()}
|
|
||||||
WHERE t.slug = ?
|
|
||||||
AND p.slug = ?
|
|
||||||
AND p.is_active = 1
|
|
||||||
AND t.is_active = 1
|
|
||||||
""",
|
|
||||||
(team_slug, page_slug),
|
|
||||||
).fetchone()
|
|
||||||
return page
|
|
||||||
|
|
||||||
|
|
||||||
def can_read_page(page, user) -> bool:
|
|
||||||
return bool(
|
|
||||||
page
|
|
||||||
and (
|
|
||||||
page["visibility"] == "public"
|
|
||||||
or (
|
|
||||||
user
|
|
||||||
and (
|
|
||||||
user["role"] in {"moderator", "admin"}
|
|
||||||
or is_team_member(page["team_id"], user["id"])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def can_edit_page(page, user) -> bool:
|
|
||||||
return bool(page and user and is_team_admin(page["team_id"], user))
|
|
||||||
|
|
||||||
|
|
||||||
def can_access_group_call_session(session_row, user: dict) -> bool:
|
|
||||||
if not session_row or not user:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if session_row["scope_kind"] == "room":
|
|
||||||
room = room_by_id(session_row["scope_id"])
|
|
||||||
return bool(room and can_access_room(room, user))
|
|
||||||
|
|
||||||
if session_row["scope_kind"] == "channel":
|
|
||||||
channel = get_channel_by_id(session_row["scope_id"])
|
|
||||||
return bool(channel and can_access_channel(channel, user))
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def can_manage_group_call_session(session_row, user: dict) -> bool:
|
|
||||||
if not session_row or not user:
|
|
||||||
return False
|
|
||||||
|
|
||||||
if user["role"] in {"moderator", "admin"}:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if int(session_row["created_by_user_id"]) == int(user["id"]):
|
|
||||||
return True
|
|
||||||
|
|
||||||
if session_row["scope_kind"] == "room":
|
|
||||||
room = room_by_id(session_row["scope_id"])
|
|
||||||
return bool(room and can_manage_room(room, user))
|
|
||||||
|
|
||||||
if session_row["scope_kind"] == "channel":
|
|
||||||
channel = get_channel_by_id(session_row["scope_id"])
|
|
||||||
return bool(channel and can_edit_team(channel["team_id"], user))
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def group_call_scope_payload(session_row, user):
|
|
||||||
payload = {
|
|
||||||
"scope_kind": session_row["scope_kind"],
|
|
||||||
"scope_id": session_row["scope_id"],
|
|
||||||
"scope_slug": session_row["scope_slug"],
|
|
||||||
}
|
|
||||||
|
|
||||||
if session_row["scope_kind"] == "room":
|
|
||||||
room = room_by_id(session_row["scope_id"])
|
|
||||||
if room:
|
|
||||||
payload["room"] = room_payload(room, user)
|
|
||||||
payload["scope_label"] = room["name"]
|
|
||||||
elif session_row["scope_kind"] == "channel":
|
|
||||||
channel = get_channel_by_id(session_row["scope_id"])
|
|
||||||
if channel:
|
|
||||||
payload["channel"] = channel_payload(channel, user)
|
|
||||||
payload["team"] = {
|
|
||||||
"slug": channel["team_slug"],
|
|
||||||
"name": channel["team_name"],
|
|
||||||
}
|
|
||||||
payload["scope_label"] = f"# {channel['name']}"
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
|
|
||||||
def group_call_permissions_payload(session_row, user):
|
|
||||||
can_manage = can_manage_group_call_session(session_row, user)
|
|
||||||
can_access = can_access_group_call_session(session_row, user)
|
|
||||||
return {
|
|
||||||
"can_manage": can_manage,
|
|
||||||
"can_end": can_manage,
|
|
||||||
"can_invite": can_access,
|
|
||||||
}
|
|
||||||
236
routes/dms.py
236
routes/dms.py
@ -1,236 +0,0 @@
|
|||||||
from .common import (
|
|
||||||
MAX_HISTORY_MESSAGES,
|
|
||||||
MAX_INCREMENTAL_MESSAGES,
|
|
||||||
MAX_MESSAGE_LENGTH,
|
|
||||||
now_iso,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
audit,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
request,
|
|
||||||
get_db,
|
|
||||||
build_message_payload,
|
|
||||||
resolve_name_color,
|
|
||||||
validate_dm_access,
|
|
||||||
dm_payload,
|
|
||||||
find_or_create_dm,
|
|
||||||
get_dm_other_user,
|
|
||||||
MESSAGE_SELECT,
|
|
||||||
resolve_owned_attachment,
|
|
||||||
resolve_owned_attachment_ids,
|
|
||||||
normalize_username,
|
|
||||||
list_dms_for_user,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_dm_routes(app):
|
|
||||||
@app.route("/api/dms")
|
|
||||||
def api_list_dms():
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
return jsonify({"dms": list_dms_for_user(user["id"])})
|
|
||||||
|
|
||||||
@app.route("/api/dms", methods=["POST"])
|
|
||||||
def api_create_or_open_dm():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
other_user_id = data.get("user_id")
|
|
||||||
username = normalize_username(data.get("username"))
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
target = None
|
|
||||||
if other_user_id is not None:
|
|
||||||
try:
|
|
||||||
parsed_id = int(other_user_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"error": "Invalid user_id."}), 400
|
|
||||||
target = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, username, role, status
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(parsed_id,),
|
|
||||||
).fetchone()
|
|
||||||
elif username:
|
|
||||||
target = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, username, role, status
|
|
||||||
FROM users
|
|
||||||
WHERE username = ?
|
|
||||||
""",
|
|
||||||
(username,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
return jsonify({"error": "User not found."}), 404
|
|
||||||
if int(target["id"]) == int(user["id"]):
|
|
||||||
return jsonify({"error": "You cannot DM yourself."}), 400
|
|
||||||
if target["status"] in {"disabled", "blacklisted"}:
|
|
||||||
return jsonify({"error": "User not available."}), 400
|
|
||||||
|
|
||||||
dm_id = find_or_create_dm(user["id"], target["id"], now_iso)
|
|
||||||
audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"]))
|
|
||||||
return jsonify({"dm": dm_payload(dm_id, user["id"], resolve_name_color)}), 201
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/messages")
|
|
||||||
def api_get_dm_messages(dm_id: int):
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
after_raw = request.args.get("after_id", "").strip()
|
|
||||||
after_id = 0
|
|
||||||
if after_raw:
|
|
||||||
try:
|
|
||||||
after_id = max(0, int(after_raw))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
if after_raw:
|
|
||||||
rows = db.execute(
|
|
||||||
f"""
|
|
||||||
{MESSAGE_SELECT}
|
|
||||||
WHERE m.direct_conversation_id = ?
|
|
||||||
AND m.parent_message_id IS NULL
|
|
||||||
AND m.id > ?
|
|
||||||
ORDER BY m.id ASC
|
|
||||||
LIMIT ?
|
|
||||||
""",
|
|
||||||
(dm_id, after_id, MAX_INCREMENTAL_MESSAGES),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = db.execute(
|
|
||||||
f"""
|
|
||||||
SELECT *
|
|
||||||
FROM (
|
|
||||||
{MESSAGE_SELECT}
|
|
||||||
WHERE m.direct_conversation_id = ?
|
|
||||||
AND m.parent_message_id IS NULL
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
|
||||||
ORDER BY id ASC
|
|
||||||
""",
|
|
||||||
(dm_id, MAX_HISTORY_MESSAGES),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
messages = [build_message_payload(row, user) for row in rows]
|
|
||||||
latest_id = messages[-1]["id"] if messages else after_id
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"dm": dm_payload(dm_id, user["id"], resolve_name_color),
|
|
||||||
"messages": messages,
|
|
||||||
"latest_id": latest_id,
|
|
||||||
"has_more": len(messages) == MAX_INCREMENTAL_MESSAGES,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/messages", methods=["POST"])
|
|
||||||
def api_send_dm_message(dm_id: int):
|
|
||||||
limited = rate_limited("send")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
text = data.get("text", "")
|
|
||||||
if not isinstance(text, str):
|
|
||||||
return jsonify({"error": "Message must be text."}), 400
|
|
||||||
|
|
||||||
text = text.strip()
|
|
||||||
if len(text) > MAX_MESSAGE_LENGTH:
|
|
||||||
return jsonify({"error": "Message is too long."}), 400
|
|
||||||
|
|
||||||
attachment_ids, attachment_error = resolve_owned_attachment_ids(
|
|
||||||
data.get("attachment_ids", []),
|
|
||||||
user["id"],
|
|
||||||
)
|
|
||||||
if attachment_error:
|
|
||||||
return attachment_error
|
|
||||||
|
|
||||||
if not attachment_ids and data.get("attachment_id") is not None:
|
|
||||||
single_attachment_id, single_attachment_error = resolve_owned_attachment(
|
|
||||||
data.get("attachment_id"),
|
|
||||||
user["id"],
|
|
||||||
)
|
|
||||||
if single_attachment_error:
|
|
||||||
return single_attachment_error
|
|
||||||
attachment_ids = [single_attachment_id] if single_attachment_id else []
|
|
||||||
|
|
||||||
attachment_id = attachment_ids[0] if attachment_ids else None
|
|
||||||
|
|
||||||
if not text and not attachment_id:
|
|
||||||
return jsonify({"error": "Message cannot be empty."}), 400
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO messages (
|
|
||||||
room_id,
|
|
||||||
channel_id,
|
|
||||||
direct_conversation_id,
|
|
||||||
user_id,
|
|
||||||
username,
|
|
||||||
text,
|
|
||||||
attachment_id,
|
|
||||||
parent_message_id,
|
|
||||||
created_at
|
|
||||||
) VALUES (NULL, NULL, ?, ?, ?, ?, ?, NULL, ?)
|
|
||||||
""",
|
|
||||||
(dm_id, user["id"], user["username"], text, attachment_id, now_iso()),
|
|
||||||
)
|
|
||||||
message_id = cursor.lastrowid
|
|
||||||
|
|
||||||
for position, extra_attachment_id in enumerate(attachment_ids):
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO message_attachments (
|
|
||||||
message_id,
|
|
||||||
attachment_id,
|
|
||||||
position
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(message_id, extra_attachment_id, position),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone()
|
|
||||||
|
|
||||||
audit("dm_message_sent", user["id"], "dm", str(dm_id), str(message_id))
|
|
||||||
return jsonify({"message": build_message_payload(row, user)}), 201
|
|
||||||
189
routes/feed.py
189
routes/feed.py
@ -1,189 +0,0 @@
|
|||||||
from .common import (
|
|
||||||
MAX_FEED_MESSAGES,
|
|
||||||
current_user,
|
|
||||||
jsonify,
|
|
||||||
request,
|
|
||||||
get_db,
|
|
||||||
build_feed_payload,
|
|
||||||
room_by_id,
|
|
||||||
can_access_room,
|
|
||||||
get_channel_by_id,
|
|
||||||
can_access_channel,
|
|
||||||
validate_dm_access,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_feed_routes(app):
|
|
||||||
@app.route("/api/feed")
|
|
||||||
def get_feed():
|
|
||||||
user = current_user()
|
|
||||||
after_raw = request.args.get("after_id", "").strip()
|
|
||||||
after_id = 0
|
|
||||||
|
|
||||||
if after_raw:
|
|
||||||
try:
|
|
||||||
after_id = max(0, int(after_raw))
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({"error": "after_id must be a non-negative integer."}), 400
|
|
||||||
|
|
||||||
base_select = f"""
|
|
||||||
SELECT
|
|
||||||
m.id,
|
|
||||||
m.room_id,
|
|
||||||
m.channel_id,
|
|
||||||
m.direct_conversation_id,
|
|
||||||
m.user_id,
|
|
||||||
m.username,
|
|
||||||
m.text,
|
|
||||||
m.created_at,
|
|
||||||
m.attachment_id,
|
|
||||||
m.parent_message_id,
|
|
||||||
(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM messages AS thread_reply
|
|
||||||
WHERE thread_reply.parent_message_id = m.id
|
|
||||||
) AS thread_reply_count,
|
|
||||||
r.slug AS room_slug,
|
|
||||||
r.name AS room_name,
|
|
||||||
r.visibility AS room_visibility,
|
|
||||||
c.slug AS channel_slug,
|
|
||||||
c.name AS channel_name,
|
|
||||||
t.slug AS team_slug,
|
|
||||||
t.name AS team_name,
|
|
||||||
a.original_name AS attachment_original_name,
|
|
||||||
a.mime_type AS attachment_mime_type,
|
|
||||||
a.size_bytes AS attachment_size_bytes,
|
|
||||||
u.role AS user_role,
|
|
||||||
us.name_color AS user_name_color
|
|
||||||
FROM messages AS m
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = m.user_id
|
|
||||||
LEFT JOIN user_settings AS us
|
|
||||||
ON us.user_id = u.id
|
|
||||||
LEFT JOIN rooms AS r
|
|
||||||
ON r.id = m.room_id
|
|
||||||
LEFT JOIN channels AS c
|
|
||||||
ON c.id = m.channel_id
|
|
||||||
AND c.is_active = 1
|
|
||||||
LEFT JOIN teams AS t
|
|
||||||
ON t.id = c.team_id
|
|
||||||
AND t.is_active = 1
|
|
||||||
LEFT JOIN attachments AS a
|
|
||||||
ON a.id = m.attachment_id
|
|
||||||
"""
|
|
||||||
|
|
||||||
params = []
|
|
||||||
if user and user["role"] in {"moderator", "admin"}:
|
|
||||||
access_clause = """
|
|
||||||
(
|
|
||||||
(m.direct_conversation_id IS NULL AND r.id IS NOT NULL AND r.is_active = 1)
|
|
||||||
OR
|
|
||||||
(m.direct_conversation_id IS NULL AND c.id IS NOT NULL AND c.is_active = 1 AND t.is_active = 1)
|
|
||||||
OR
|
|
||||||
(
|
|
||||||
m.direct_conversation_id IS NOT NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM direct_conversation_members AS dcm
|
|
||||||
WHERE dcm.direct_conversation_id = m.direct_conversation_id
|
|
||||||
AND dcm.user_id = ?
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
params.append(user["id"])
|
|
||||||
elif user:
|
|
||||||
access_clause = """
|
|
||||||
(
|
|
||||||
(
|
|
||||||
m.direct_conversation_id IS NULL
|
|
||||||
AND r.id IS NOT NULL
|
|
||||||
AND r.is_active = 1
|
|
||||||
AND (
|
|
||||||
r.visibility = 'public'
|
|
||||||
OR EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM room_members AS rm
|
|
||||||
WHERE rm.room_id = r.id
|
|
||||||
AND rm.user_id = ?
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
(
|
|
||||||
m.direct_conversation_id IS NULL
|
|
||||||
AND c.id IS NOT NULL
|
|
||||||
AND c.is_active = 1
|
|
||||||
AND t.is_active = 1
|
|
||||||
AND c.legacy_room_id IS NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM team_members AS tm
|
|
||||||
WHERE tm.team_id = c.team_id
|
|
||||||
AND tm.user_id = ?
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR
|
|
||||||
(
|
|
||||||
m.direct_conversation_id IS NOT NULL
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM direct_conversation_members AS dcm
|
|
||||||
WHERE dcm.direct_conversation_id = m.direct_conversation_id
|
|
||||||
AND dcm.user_id = ?
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
params.extend([user["id"], user["id"], user["id"]])
|
|
||||||
else:
|
|
||||||
access_clause = """
|
|
||||||
m.direct_conversation_id IS NULL
|
|
||||||
AND r.id IS NOT NULL
|
|
||||||
AND r.is_active = 1
|
|
||||||
AND r.visibility = 'public'
|
|
||||||
"""
|
|
||||||
|
|
||||||
if after_raw:
|
|
||||||
query = f"""
|
|
||||||
{base_select}
|
|
||||||
WHERE m.parent_message_id IS NULL
|
|
||||||
AND {access_clause}
|
|
||||||
AND m.id > ?
|
|
||||||
ORDER BY m.id ASC
|
|
||||||
LIMIT ?
|
|
||||||
"""
|
|
||||||
query_params = [*params, after_id, MAX_FEED_MESSAGES]
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(query, tuple(query_params)).fetchall()
|
|
||||||
|
|
||||||
entries = [build_feed_payload(row, user) for row in rows]
|
|
||||||
latest_id = entries[-1]["id"] if entries else after_id
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"entries": entries,
|
|
||||||
"latest_id": latest_id,
|
|
||||||
"has_more": len(entries) == MAX_FEED_MESSAGES,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
query = f"""
|
|
||||||
SELECT *
|
|
||||||
FROM (
|
|
||||||
{base_select}
|
|
||||||
WHERE m.parent_message_id IS NULL
|
|
||||||
AND {access_clause}
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ?
|
|
||||||
)
|
|
||||||
ORDER BY id ASC
|
|
||||||
"""
|
|
||||||
query_params = [*params, MAX_FEED_MESSAGES]
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(query, tuple(query_params)).fetchall()
|
|
||||||
|
|
||||||
entries = [build_feed_payload(row, user) for row in rows]
|
|
||||||
latest_id = entries[-1]["id"] if entries else 0
|
|
||||||
return jsonify({"entries": entries, "latest_id": latest_id, "has_more": False})
|
|
||||||
1179
routes/messages.py
1179
routes/messages.py
File diff suppressed because it is too large
Load Diff
@ -1,336 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
TYPING_TTL_SECONDS,
|
|
||||||
PRESENCE_TTL_SECONDS,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
current_user,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
get_db,
|
|
||||||
db_write,
|
|
||||||
get_room_by_slug,
|
|
||||||
can_access_room,
|
|
||||||
can_write_room,
|
|
||||||
get_channel_by_slug,
|
|
||||||
can_access_channel,
|
|
||||||
can_write_channel,
|
|
||||||
validate_dm_access,
|
|
||||||
update_presence,
|
|
||||||
room_presence_count,
|
|
||||||
channel_presence_count,
|
|
||||||
dm_presence_count,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_presence_routes(app):
|
|
||||||
@app.route("/api/presence", methods=["POST"])
|
|
||||||
def api_presence_ping():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
view_name = str(data.get("view", "")).strip()[:32] or None
|
|
||||||
room_id = data.get("room_id")
|
|
||||||
channel_id = data.get("channel_id")
|
|
||||||
dm_id = data.get("dm_id")
|
|
||||||
|
|
||||||
try:
|
|
||||||
room_id = int(room_id) if room_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
room_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
channel_id = int(channel_id) if channel_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
channel_id = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
dm_id = int(dm_id) if dm_id is not None else None
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
dm_id = None
|
|
||||||
|
|
||||||
update_presence(db_write, user["id"], view_name, room_id, channel_id, dm_id)
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/presence")
|
|
||||||
def api_room_presence(slug: str):
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
user = current_user()
|
|
||||||
if not room or not can_access_room(room, user):
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"room_id": room["id"],
|
|
||||||
"online_count": room_presence_count(PRESENCE_TTL_SECONDS, room["id"]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/presence")
|
|
||||||
def api_channel_presence(team_slug: str, channel_slug: str):
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
user = current_user()
|
|
||||||
if not channel or not can_access_channel(channel, user):
|
|
||||||
return jsonify({"error": "Channel not found."}), 404
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"channel_id": channel["id"],
|
|
||||||
"online_count": channel_presence_count(PRESENCE_TTL_SECONDS, channel["id"]),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/presence")
|
|
||||||
def api_dm_presence(dm_id: int):
|
|
||||||
user = current_user()
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"dm_id": dm_id,
|
|
||||||
"online_count": dm_presence_count(PRESENCE_TTL_SECONDS, dm_id),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/typing", methods=["POST"])
|
|
||||||
def set_typing(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("typing")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room or not can_write_room(room, user):
|
|
||||||
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
typing = bool(data.get("typing"))
|
|
||||||
|
|
||||||
if typing:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO typing_events (
|
|
||||||
room_id,
|
|
||||||
user_id,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(room_id, user_id)
|
|
||||||
DO UPDATE SET updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(room["id"], user["id"], time.time()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
db_write(
|
|
||||||
"DELETE FROM typing_events WHERE room_id = ? AND user_id = ?",
|
|
||||||
(room["id"], user["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/typing")
|
|
||||||
def get_typing(slug: str):
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
user = current_user()
|
|
||||||
|
|
||||||
if not room or not can_access_room(room, user):
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("DELETE FROM typing_events WHERE updated_at < ?", (cutoff,))
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username
|
|
||||||
FROM typing_events AS t
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = t.user_id
|
|
||||||
WHERE t.room_id = ?
|
|
||||||
AND t.user_id != ?
|
|
||||||
AND t.updated_at >= ?
|
|
||||||
ORDER BY u.username COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(room["id"], user["id"] if user else -1, cutoff),
|
|
||||||
).fetchall()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
|
||||||
|
|
||||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing", methods=["POST"])
|
|
||||||
def api_set_channel_typing(team_slug: str, channel_slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("typing")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
if not channel:
|
|
||||||
return jsonify({"error": "Channel not found."}), 404
|
|
||||||
|
|
||||||
if not can_write_channel(channel, user):
|
|
||||||
return jsonify({"error": "You cannot use typing indicators here.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
typing = bool(data.get("typing"))
|
|
||||||
|
|
||||||
if typing:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO channel_typing_events (
|
|
||||||
channel_id,
|
|
||||||
user_id,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(channel_id, user_id)
|
|
||||||
DO UPDATE SET updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(channel["id"], user["id"], time.time()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
db_write(
|
|
||||||
"DELETE FROM channel_typing_events WHERE channel_id = ? AND user_id = ?",
|
|
||||||
(channel["id"], user["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
|
|
||||||
@app.route("/api/teams/<string:team_slug>/channels/<string:channel_slug>/typing")
|
|
||||||
def api_get_channel_typing(team_slug: str, channel_slug: str):
|
|
||||||
channel = get_channel_by_slug(team_slug, channel_slug)
|
|
||||||
if not channel:
|
|
||||||
return jsonify({"error": "Channel not found."}), 404
|
|
||||||
|
|
||||||
user = current_user()
|
|
||||||
if not can_access_channel(channel, user):
|
|
||||||
if not user:
|
|
||||||
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
|
||||||
return jsonify({"error": "You do not have access to this channel.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("DELETE FROM channel_typing_events WHERE updated_at < ?", (cutoff,))
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username
|
|
||||||
FROM channel_typing_events AS t
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = t.user_id
|
|
||||||
WHERE t.channel_id = ?
|
|
||||||
AND t.user_id != ?
|
|
||||||
AND t.updated_at >= ?
|
|
||||||
ORDER BY u.username COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(channel["id"], user["id"] if user else -1, cutoff),
|
|
||||||
).fetchall()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/typing", methods=["POST"])
|
|
||||||
def api_set_dm_typing(dm_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("typing")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
typing = bool(data.get("typing"))
|
|
||||||
|
|
||||||
if typing:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO dm_typing_events (
|
|
||||||
direct_conversation_id,
|
|
||||||
user_id,
|
|
||||||
updated_at
|
|
||||||
) VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(direct_conversation_id, user_id)
|
|
||||||
DO UPDATE SET updated_at = excluded.updated_at
|
|
||||||
""",
|
|
||||||
(dm_id, user["id"], time.time()),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
DELETE FROM dm_typing_events
|
|
||||||
WHERE direct_conversation_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(dm_id, user["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
return jsonify({"status": "ok"})
|
|
||||||
|
|
||||||
@app.route("/api/dms/<int:dm_id>/typing")
|
|
||||||
def api_get_dm_typing(dm_id: int):
|
|
||||||
user = current_user()
|
|
||||||
if not validate_dm_access(dm_id, user):
|
|
||||||
return jsonify({"error": "DM not found."}), 404
|
|
||||||
|
|
||||||
cutoff = time.time() - TYPING_TTL_SECONDS
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("DELETE FROM dm_typing_events WHERE updated_at < ?", (cutoff,))
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username
|
|
||||||
FROM dm_typing_events AS t
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = t.user_id
|
|
||||||
WHERE t.direct_conversation_id = ?
|
|
||||||
AND t.user_id != ?
|
|
||||||
AND t.updated_at >= ?
|
|
||||||
ORDER BY u.username COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(dm_id, user["id"], cutoff),
|
|
||||||
).fetchall()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return jsonify({"users": [{"id": row["id"], "username": row["username"]} for row in rows]})
|
|
||||||
@ -1,518 +0,0 @@
|
|||||||
from .common import (
|
|
||||||
MAX_CUSTOM_CSS_LENGTH,
|
|
||||||
MAX_DISPLAY_NAME_LENGTH,
|
|
||||||
MAX_LOCATION_LENGTH,
|
|
||||||
MAX_MESSAGE_LENGTH,
|
|
||||||
MAX_PROFILE_BADGE_LENGTH,
|
|
||||||
MAX_PROFILE_BIO_LENGTH,
|
|
||||||
MAX_WEBSITE_LENGTH,
|
|
||||||
DM_LAYOUT_MODES,
|
|
||||||
LAST_ACTIVE_VISIBILITIES,
|
|
||||||
PRESENCE_MODES,
|
|
||||||
PROFILE_BIO_MODES,
|
|
||||||
PROFILE_VISIBILITIES,
|
|
||||||
THEME_MODES,
|
|
||||||
now_iso,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
current_user,
|
|
||||||
require_user,
|
|
||||||
require_writable_user,
|
|
||||||
normalize_username,
|
|
||||||
audit,
|
|
||||||
user_payload,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
get_db,
|
|
||||||
db_write,
|
|
||||||
profile_payload,
|
|
||||||
can_view_profile_record,
|
|
||||||
valid_hex_color,
|
|
||||||
normalized_hex_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_profile_routes(app):
|
|
||||||
@app.route("/api/profile")
|
|
||||||
def api_get_my_profile():
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
user_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until,
|
|
||||||
created_at,
|
|
||||||
last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
profile_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
FROM user_profiles
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
settings_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
name_color,
|
|
||||||
presence_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge
|
|
||||||
FROM user_settings
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
|
|
||||||
|
|
||||||
@app.route("/api/profile", methods=["PATCH"])
|
|
||||||
def api_update_my_profile():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
limited = rate_limited("profile")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
display_name = str(data.get("display_name", "")).strip()
|
|
||||||
badge_text = str(data.get("badge_text", "")).strip()
|
|
||||||
bio = str(data.get("bio", "")).strip()
|
|
||||||
bio_mode = str(data.get("bio_mode", "txt")).strip().lower()
|
|
||||||
website = str(data.get("website", "")).strip()
|
|
||||||
location = str(data.get("location", "")).strip()
|
|
||||||
status_message = str(data.get("status_message", "")).strip()
|
|
||||||
|
|
||||||
banner_attachment_id = data.get("banner_attachment_id")
|
|
||||||
if banner_attachment_id not in (None, ""):
|
|
||||||
try:
|
|
||||||
banner_attachment_id = int(banner_attachment_id)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({"error": "Invalid banner attachment."}), 400
|
|
||||||
else:
|
|
||||||
banner_attachment_id = None
|
|
||||||
|
|
||||||
if len(display_name) > MAX_DISPLAY_NAME_LENGTH:
|
|
||||||
return jsonify({"error": "Display name is too long."}), 400
|
|
||||||
if len(badge_text) > MAX_PROFILE_BADGE_LENGTH:
|
|
||||||
return jsonify({"error": "Badge text is too long."}), 400
|
|
||||||
if len(bio) > MAX_PROFILE_BIO_LENGTH:
|
|
||||||
return jsonify({"error": "Bio is too long."}), 400
|
|
||||||
if bio_mode not in PROFILE_BIO_MODES:
|
|
||||||
return jsonify({"error": "Invalid bio mode."}), 400
|
|
||||||
if len(website) > MAX_WEBSITE_LENGTH:
|
|
||||||
return jsonify({"error": "Website is too long."}), 400
|
|
||||||
if len(location) > MAX_LOCATION_LENGTH:
|
|
||||||
return jsonify({"error": "Location is too long."}), 400
|
|
||||||
if len(status_message) > 160:
|
|
||||||
return jsonify({"error": "Status message is too long."}), 400
|
|
||||||
|
|
||||||
if banner_attachment_id is not None:
|
|
||||||
with get_db() as db:
|
|
||||||
attachment = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT id, uploader_user_id
|
|
||||||
FROM attachments
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(banner_attachment_id,),
|
|
||||||
).fetchone()
|
|
||||||
if not attachment:
|
|
||||||
return jsonify({"error": "Banner attachment not found."}), 404
|
|
||||||
if attachment["uploader_user_id"] != user["id"]:
|
|
||||||
return jsonify({"error": "You can only use your own uploaded image."}), 403
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE user_profiles
|
|
||||||
SET
|
|
||||||
display_name = ?,
|
|
||||||
badge_text = ?,
|
|
||||||
bio = ?,
|
|
||||||
bio_mode = ?,
|
|
||||||
website = ?,
|
|
||||||
location = ?,
|
|
||||||
status_message = ?,
|
|
||||||
banner_attachment_id = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
display_name or user["username"],
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
now_iso(),
|
|
||||||
user["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
user_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until,
|
|
||||||
created_at,
|
|
||||||
last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
profile_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
FROM user_profiles
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
settings_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
name_color,
|
|
||||||
presence_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge
|
|
||||||
FROM user_settings
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
audit("profile_updated", user["id"], "user", str(user["id"]))
|
|
||||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, user)})
|
|
||||||
|
|
||||||
@app.route("/api/profiles/<string:username>")
|
|
||||||
def api_get_public_profile(username: str):
|
|
||||||
normalized = normalize_username(username)
|
|
||||||
viewer = current_user()
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
user_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
username,
|
|
||||||
role,
|
|
||||||
status,
|
|
||||||
restricted_until,
|
|
||||||
created_at,
|
|
||||||
last_login_at
|
|
||||||
FROM users
|
|
||||||
WHERE username = ?
|
|
||||||
""",
|
|
||||||
(normalized,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not user_row:
|
|
||||||
return jsonify({"error": "Profile not found."}), 404
|
|
||||||
|
|
||||||
profile_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
user_id,
|
|
||||||
display_name,
|
|
||||||
badge_text,
|
|
||||||
bio,
|
|
||||||
bio_mode,
|
|
||||||
website,
|
|
||||||
location,
|
|
||||||
status_message,
|
|
||||||
banner_attachment_id,
|
|
||||||
updated_at
|
|
||||||
FROM user_profiles
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user_row["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
settings_row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
name_color,
|
|
||||||
presence_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge
|
|
||||||
FROM user_settings
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user_row["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not can_view_profile_record(settings_row, viewer, user_row["id"]):
|
|
||||||
if not viewer:
|
|
||||||
return jsonify({"error": "Login required.", "code": "login_required"}), 401
|
|
||||||
return jsonify({"error": "You do not have access to this profile.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
return jsonify({"profile": profile_payload(user_payload, user_row, profile_row, settings_row, viewer)})
|
|
||||||
|
|
||||||
@app.route("/api/settings")
|
|
||||||
def api_get_settings():
|
|
||||||
user, error = require_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
accent_color,
|
|
||||||
name_color,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
updated_at
|
|
||||||
FROM user_settings
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"settings": {
|
|
||||||
"accent_color": row["accent_color"],
|
|
||||||
"name_color": row["name_color"],
|
|
||||||
"custom_css": row["custom_css"],
|
|
||||||
"theme_mode": row["theme_mode"],
|
|
||||||
"profile_background_color": row["profile_background_color"],
|
|
||||||
"profile_banner_color": row["profile_banner_color"],
|
|
||||||
"profile_visibility": row["profile_visibility"],
|
|
||||||
"last_active_visibility": row["last_active_visibility"],
|
|
||||||
"presence_mode": row["presence_mode"],
|
|
||||||
"dm_layout_mode": row["dm_layout_mode"],
|
|
||||||
"show_presence_badge": bool(row["show_presence_badge"]),
|
|
||||||
"updated_at": row["updated_at"],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/settings", methods=["PATCH"])
|
|
||||||
def api_update_settings():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
limited = rate_limited("settings")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
accent_color = data.get("accent_color", "#f7dce8")
|
|
||||||
name_color = data.get("name_color")
|
|
||||||
custom_css = str(data.get("custom_css", ""))
|
|
||||||
theme_mode = str(data.get("theme_mode", "dark")).strip().lower()
|
|
||||||
profile_background_color = data.get("profile_background_color")
|
|
||||||
profile_banner_color = data.get("profile_banner_color")
|
|
||||||
profile_visibility = str(data.get("profile_visibility", "public")).strip().lower()
|
|
||||||
last_active_visibility = str(data.get("last_active_visibility", "everyone")).strip().lower()
|
|
||||||
presence_mode = str(data.get("presence_mode", "online")).strip().lower()
|
|
||||||
dm_layout_mode = str(data.get("dm_layout_mode", "cards")).strip().lower()
|
|
||||||
show_presence_badge_raw = data.get("show_presence_badge", True)
|
|
||||||
|
|
||||||
if not valid_hex_color(accent_color):
|
|
||||||
return jsonify({"error": "Invalid accent color."}), 400
|
|
||||||
if name_color not in (None, "") and not valid_hex_color(name_color):
|
|
||||||
return jsonify({"error": "Invalid name color."}), 400
|
|
||||||
if profile_background_color not in (None, "") and not valid_hex_color(profile_background_color):
|
|
||||||
return jsonify({"error": "Invalid profile background color."}), 400
|
|
||||||
if profile_banner_color not in (None, "") and not valid_hex_color(profile_banner_color):
|
|
||||||
return jsonify({"error": "Invalid profile banner color."}), 400
|
|
||||||
if len(custom_css) > MAX_CUSTOM_CSS_LENGTH:
|
|
||||||
return jsonify({"error": "Custom CSS is too long."}), 400
|
|
||||||
if theme_mode not in THEME_MODES:
|
|
||||||
return jsonify({"error": "Invalid theme mode."}), 400
|
|
||||||
if profile_visibility not in PROFILE_VISIBILITIES:
|
|
||||||
return jsonify({"error": "Invalid profile visibility."}), 400
|
|
||||||
if last_active_visibility not in LAST_ACTIVE_VISIBILITIES:
|
|
||||||
return jsonify({"error": "Invalid last active visibility."}), 400
|
|
||||||
if presence_mode not in PRESENCE_MODES:
|
|
||||||
return jsonify({"error": "Invalid presence mode."}), 400
|
|
||||||
if dm_layout_mode not in DM_LAYOUT_MODES:
|
|
||||||
return jsonify({"error": "Invalid dm layout mode."}), 400
|
|
||||||
|
|
||||||
show_presence_badge = 1 if bool(show_presence_badge_raw) else 0
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
UPDATE user_settings
|
|
||||||
SET
|
|
||||||
accent_color = ?,
|
|
||||||
name_color = ?,
|
|
||||||
custom_css = ?,
|
|
||||||
theme_mode = ?,
|
|
||||||
profile_background_color = ?,
|
|
||||||
profile_banner_color = ?,
|
|
||||||
profile_visibility = ?,
|
|
||||||
last_active_visibility = ?,
|
|
||||||
presence_mode = ?,
|
|
||||||
dm_layout_mode = ?,
|
|
||||||
show_presence_badge = ?,
|
|
||||||
updated_at = ?
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
str(accent_color).lower(),
|
|
||||||
normalized_hex_color(name_color) if name_color else None,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
normalized_hex_color(profile_background_color) if profile_background_color else None,
|
|
||||||
normalized_hex_color(profile_banner_color) if profile_banner_color else None,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
now_iso(),
|
|
||||||
user["id"],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
row = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
accent_color,
|
|
||||||
name_color,
|
|
||||||
custom_css,
|
|
||||||
theme_mode,
|
|
||||||
profile_background_color,
|
|
||||||
profile_banner_color,
|
|
||||||
profile_visibility,
|
|
||||||
last_active_visibility,
|
|
||||||
presence_mode,
|
|
||||||
dm_layout_mode,
|
|
||||||
show_presence_badge,
|
|
||||||
updated_at
|
|
||||||
FROM user_settings
|
|
||||||
WHERE user_id = ?
|
|
||||||
""",
|
|
||||||
(user["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
audit("settings_updated", user["id"], "user", str(user["id"]))
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"settings": {
|
|
||||||
**dict(row),
|
|
||||||
"show_presence_badge": bool(row["show_presence_badge"]),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/scheduled-messages", methods=["POST"])
|
|
||||||
def api_create_scheduled_message():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
text = str(data.get("text", "")).strip()
|
|
||||||
deliver_at = str(data.get("deliver_at", "")).strip()
|
|
||||||
scope_kind = str(data.get("scope_kind", "")).strip()
|
|
||||||
scope_id = data.get("scope_id")
|
|
||||||
|
|
||||||
if not text:
|
|
||||||
return jsonify({"error": "Scheduled message text is required."}), 400
|
|
||||||
if len(text) > MAX_MESSAGE_LENGTH:
|
|
||||||
return jsonify({"error": "Scheduled message is too long."}), 400
|
|
||||||
if not deliver_at:
|
|
||||||
return jsonify({"error": "deliver_at is required."}), 400
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"status": "stubbed",
|
|
||||||
"scheduled_message": {
|
|
||||||
"id": 0,
|
|
||||||
"text": text,
|
|
||||||
"deliver_at": deliver_at,
|
|
||||||
"scope_kind": scope_kind,
|
|
||||||
"scope_id": scope_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
), 201
|
|
||||||
526
routes/rooms.py
526
routes/rooms.py
@ -1,526 +0,0 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
from .common import (
|
|
||||||
INVITE_TTL_HOURS,
|
|
||||||
ROOM_SLUG_RE,
|
|
||||||
ROOM_VISIBILITIES,
|
|
||||||
now_iso,
|
|
||||||
parse_iso,
|
|
||||||
utc_now,
|
|
||||||
csrf_valid,
|
|
||||||
rate_limited,
|
|
||||||
current_user,
|
|
||||||
require_admin,
|
|
||||||
require_writable_user,
|
|
||||||
audit,
|
|
||||||
hash_identifier,
|
|
||||||
normalize_username,
|
|
||||||
safe_json,
|
|
||||||
jsonify,
|
|
||||||
get_db,
|
|
||||||
db_write,
|
|
||||||
get_room_by_slug,
|
|
||||||
room_payload,
|
|
||||||
can_access_room,
|
|
||||||
can_manage_room,
|
|
||||||
get_channel_by_legacy_room_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register_room_routes(app):
|
|
||||||
@app.route("/api/rooms")
|
|
||||||
def list_rooms():
|
|
||||||
user = current_user()
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_by_user_id,
|
|
||||||
is_active
|
|
||||||
FROM rooms
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY
|
|
||||||
CASE WHEN slug = 'general' THEN 0 ELSE 1 END,
|
|
||||||
name COLLATE NOCASE
|
|
||||||
"""
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
rooms = [room_payload(room, user) for room in rows if can_access_room(room, user)]
|
|
||||||
return jsonify({"rooms": rooms, "authenticated": bool(user)})
|
|
||||||
|
|
||||||
@app.route("/api/rooms", methods=["POST"])
|
|
||||||
def create_room():
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("admin")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
name = str(data.get("name", "")).strip()
|
|
||||||
description = str(data.get("description", "")).strip()[:300]
|
|
||||||
visibility = data.get("visibility", "public")
|
|
||||||
|
|
||||||
if not name or len(name) > 60:
|
|
||||||
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
|
|
||||||
if visibility not in ROOM_VISIBILITIES:
|
|
||||||
return jsonify({"error": "Invalid room visibility."}), 400
|
|
||||||
|
|
||||||
base_slug = re.sub(r"[^a-z0-9-]+", "-", name.lower()).strip("-")
|
|
||||||
base_slug = base_slug[:48].strip("-")
|
|
||||||
|
|
||||||
if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug):
|
|
||||||
return jsonify({"error": "Room name cannot create a valid room slug."}), 400
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
slug = base_slug
|
|
||||||
suffix = 2
|
|
||||||
while db.execute("SELECT 1 FROM rooms WHERE slug = ?", (slug,)).fetchone():
|
|
||||||
slug = f"{base_slug[:44]}-{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
|
|
||||||
cursor = db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO rooms (
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
visibility,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, 1)
|
|
||||||
""",
|
|
||||||
(slug, name, description, visibility, admin["id"], now_iso()),
|
|
||||||
)
|
|
||||||
room_id = cursor.lastrowid
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO room_members (
|
|
||||||
room_id,
|
|
||||||
user_id,
|
|
||||||
membership_role,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, 'owner', ?)
|
|
||||||
""",
|
|
||||||
(room_id, admin["id"], now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
general_team = db.execute(
|
|
||||||
"SELECT id FROM teams WHERE slug = 'general' AND is_active = 1"
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if general_team:
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO channels (
|
|
||||||
team_id,
|
|
||||||
legacy_room_id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
is_active
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
|
||||||
""",
|
|
||||||
(
|
|
||||||
general_team["id"],
|
|
||||||
room_id,
|
|
||||||
slug,
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
admin["id"],
|
|
||||||
now_iso(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
audit("room_created", admin["id"], "room", str(room_id), f"{slug}:{visibility}")
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"room": {
|
|
||||||
"id": room_id,
|
|
||||||
"slug": slug,
|
|
||||||
"name": name,
|
|
||||||
"description": description,
|
|
||||||
"visibility": visibility,
|
|
||||||
"can_read": True,
|
|
||||||
"can_write": True,
|
|
||||||
"can_manage": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
), 201
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>", methods=["PATCH"])
|
|
||||||
def update_room(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("admin")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
if room["slug"] == "general" and user["role"] != "admin":
|
|
||||||
return jsonify({"error": "Only an administrator can modify the default room."}), 403
|
|
||||||
|
|
||||||
if not can_manage_room(room, user):
|
|
||||||
return jsonify({"error": "Room owner or administrator access required.", "code": "forbidden"}), 403
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
name = str(data.get("name", room["name"])).strip()
|
|
||||||
description = str(data.get("description", room["description"] or "")).strip()[:300]
|
|
||||||
visibility = data.get("visibility", room["visibility"])
|
|
||||||
|
|
||||||
if not name or len(name) > 60:
|
|
||||||
return jsonify({"error": "Room name must be 1 to 60 characters."}), 400
|
|
||||||
if visibility not in ROOM_VISIBILITIES:
|
|
||||||
return jsonify({"error": "Invalid room visibility."}), 400
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"UPDATE rooms SET name = ?, description = ?, visibility = ? WHERE id = ?",
|
|
||||||
(name, description, visibility, room["id"]),
|
|
||||||
)
|
|
||||||
db_write(
|
|
||||||
"UPDATE channels SET name = ?, description = ? WHERE legacy_room_id = ?",
|
|
||||||
(name, description, room["id"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("room_updated", user["id"], "room", str(room["id"]), f"name={name};visibility={visibility}")
|
|
||||||
return jsonify({"status": "updated"})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>", methods=["DELETE"])
|
|
||||||
def delete_room(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("admin")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
if room["slug"] == "general":
|
|
||||||
return jsonify({"error": "The default room cannot be deleted."}), 400
|
|
||||||
|
|
||||||
db_write("DELETE FROM rooms WHERE id = ?", (room["id"],))
|
|
||||||
audit("room_deleted", admin["id"], "room", str(room["id"]), room["slug"])
|
|
||||||
return jsonify({"status": "deleted"})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/messages", methods=["DELETE"])
|
|
||||||
def clear_room_messages(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
moderator, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
limited = rate_limited("admin")
|
|
||||||
if limited:
|
|
||||||
return limited
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
cursor = db_write(
|
|
||||||
"""
|
|
||||||
DELETE FROM messages
|
|
||||||
WHERE room_id = ?
|
|
||||||
AND direct_conversation_id IS NULL
|
|
||||||
""",
|
|
||||||
(room["id"],),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("room_messages_cleared", moderator["id"], "room", str(room["id"]), str(cursor.rowcount))
|
|
||||||
return jsonify({"status": "cleared", "deleted_messages": cursor.rowcount})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/members")
|
|
||||||
def list_room_members(slug: str):
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
if room["visibility"] != "private":
|
|
||||||
return jsonify({"error": "This room is public."}), 400
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
rows = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
u.id,
|
|
||||||
u.username,
|
|
||||||
u.role,
|
|
||||||
rm.membership_role,
|
|
||||||
rm.added_at
|
|
||||||
FROM room_members AS rm
|
|
||||||
JOIN users AS u
|
|
||||||
ON u.id = rm.user_id
|
|
||||||
WHERE rm.room_id = ?
|
|
||||||
ORDER BY
|
|
||||||
CASE rm.membership_role
|
|
||||||
WHEN 'owner' THEN 0
|
|
||||||
ELSE 1
|
|
||||||
END,
|
|
||||||
u.username COLLATE NOCASE
|
|
||||||
""",
|
|
||||||
(room["id"],),
|
|
||||||
).fetchall()
|
|
||||||
|
|
||||||
return jsonify(
|
|
||||||
{
|
|
||||||
"room": room_payload(room, admin),
|
|
||||||
"members": [
|
|
||||||
{
|
|
||||||
"id": row["id"],
|
|
||||||
"username": row["username"],
|
|
||||||
"role": row["role"],
|
|
||||||
"membership_role": row["membership_role"],
|
|
||||||
"added_at": row["added_at"],
|
|
||||||
}
|
|
||||||
for row in rows
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/members", methods=["POST"])
|
|
||||||
def add_room_member(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
if room["visibility"] != "private":
|
|
||||||
return jsonify({"error": "Members can only be managed for private rooms."}), 400
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = safe_json()
|
|
||||||
except ValueError as error:
|
|
||||||
return jsonify({"error": str(error)}), 400
|
|
||||||
|
|
||||||
username = normalize_username(data.get("username"))
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
target = db.execute(
|
|
||||||
"SELECT id, username FROM users WHERE username = ?",
|
|
||||||
(username,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not target:
|
|
||||||
return jsonify({"error": "User not found."}), 404
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO room_members (
|
|
||||||
room_id,
|
|
||||||
user_id,
|
|
||||||
membership_role,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, 'member', ?)
|
|
||||||
""",
|
|
||||||
(room["id"], target["id"], now_iso()),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("room_member_added", admin["id"], "room", str(room["id"]), target["username"])
|
|
||||||
return jsonify({"status": "added"}), 201
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/members/<int:user_id>", methods=["DELETE"])
|
|
||||||
def remove_room_member(slug: str, user_id: int):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
member = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT membership_role
|
|
||||||
FROM room_members
|
|
||||||
WHERE room_id = ?
|
|
||||||
AND user_id = ?
|
|
||||||
""",
|
|
||||||
(room["id"], user_id),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not member:
|
|
||||||
return jsonify({"error": "Room member not found."}), 404
|
|
||||||
|
|
||||||
if member["membership_role"] == "owner":
|
|
||||||
return jsonify({"error": "The room owner cannot be removed."}), 400
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"DELETE FROM room_members WHERE room_id = ? AND user_id = ?",
|
|
||||||
(room["id"], user_id),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("room_member_removed", admin["id"], "room", str(room["id"]), str(user_id))
|
|
||||||
return jsonify({"status": "removed"})
|
|
||||||
|
|
||||||
@app.route("/api/rooms/<string:slug>/invites", methods=["POST"])
|
|
||||||
def create_room_invite(slug: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
admin, error = require_admin()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
room = get_room_by_slug(slug)
|
|
||||||
if not room:
|
|
||||||
return jsonify({"error": "Room not found."}), 404
|
|
||||||
|
|
||||||
if room["visibility"] != "private":
|
|
||||||
return jsonify({"error": "Invite links are only available for private rooms."}), 400
|
|
||||||
|
|
||||||
token = app.secrets.token_urlsafe(32)
|
|
||||||
expires_at = (utc_now() + app.timedelta(hours=INVITE_TTL_HOURS)).isoformat()
|
|
||||||
|
|
||||||
db_write(
|
|
||||||
"""
|
|
||||||
INSERT INTO room_invites (
|
|
||||||
room_id,
|
|
||||||
token_hash,
|
|
||||||
created_by_user_id,
|
|
||||||
created_at,
|
|
||||||
expires_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(room["id"], hash_identifier(token), admin["id"], now_iso(), expires_at),
|
|
||||||
)
|
|
||||||
|
|
||||||
audit("room_invite_created", admin["id"], "room", str(room["id"]))
|
|
||||||
return jsonify({"invite": {"url": f"/?invite={token}", "expires_at": expires_at}}), 201
|
|
||||||
|
|
||||||
@app.route("/api/invites/<string:token>/accept", methods=["POST"])
|
|
||||||
def accept_room_invite(token: str):
|
|
||||||
if not csrf_valid():
|
|
||||||
return jsonify({"error": "Security token expired.", "code": "csrf"}), 403
|
|
||||||
|
|
||||||
user, error = require_writable_user()
|
|
||||||
if error:
|
|
||||||
return error
|
|
||||||
|
|
||||||
token_hash = hash_identifier(token)
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
invite = db.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
ri.id,
|
|
||||||
ri.room_id,
|
|
||||||
ri.expires_at,
|
|
||||||
ri.used_at,
|
|
||||||
r.slug,
|
|
||||||
r.name,
|
|
||||||
r.description,
|
|
||||||
r.visibility,
|
|
||||||
r.created_by_user_id,
|
|
||||||
r.is_active
|
|
||||||
FROM room_invites AS ri
|
|
||||||
JOIN rooms AS r
|
|
||||||
ON r.id = ri.room_id
|
|
||||||
WHERE ri.token_hash = ?
|
|
||||||
""",
|
|
||||||
(token_hash,),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not invite:
|
|
||||||
return jsonify({"error": "Invite link is invalid."}), 404
|
|
||||||
|
|
||||||
if invite["used_at"] or parse_iso(invite["expires_at"]) <= utc_now():
|
|
||||||
return jsonify({"error": "Invite link has expired or was already used."}), 400
|
|
||||||
|
|
||||||
if invite["visibility"] != "private" or not invite["is_active"]:
|
|
||||||
return jsonify({"error": "This room is no longer available."}), 404
|
|
||||||
|
|
||||||
with get_db() as db:
|
|
||||||
db.execute("BEGIN IMMEDIATE")
|
|
||||||
fresh = db.execute(
|
|
||||||
"SELECT id, used_at, expires_at FROM room_invites WHERE id = ?",
|
|
||||||
(invite["id"],),
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not fresh or fresh["used_at"] or parse_iso(fresh["expires_at"]) <= utc_now():
|
|
||||||
db.rollback()
|
|
||||||
return jsonify({"error": "Invite link has expired or was already used."}), 400
|
|
||||||
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR IGNORE INTO room_members (
|
|
||||||
room_id,
|
|
||||||
user_id,
|
|
||||||
membership_role,
|
|
||||||
added_at
|
|
||||||
) VALUES (?, ?, 'member', ?)
|
|
||||||
""",
|
|
||||||
(invite["room_id"], user["id"], now_iso()),
|
|
||||||
)
|
|
||||||
db.execute(
|
|
||||||
"""
|
|
||||||
UPDATE room_invites
|
|
||||||
SET
|
|
||||||
used_at = ?,
|
|
||||||
used_by_user_id = ?
|
|
||||||
WHERE id = ?
|
|
||||||
""",
|
|
||||||
(now_iso(), user["id"], invite["id"]),
|
|
||||||
)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
audit("room_invite_accepted", user["id"], "room", str(invite["room_id"]))
|
|
||||||
room = get_room_by_slug(invite["slug"])
|
|
||||||
return jsonify({"status": "joined", "room": room_payload(room, user)})
|
|
||||||
1203
routes/teams.py
1203
routes/teams.py
File diff suppressed because it is too large
Load Diff
97
sanitizer.py
Normal file
97
sanitizer.py
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import html
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED_TAGS = {
|
||||||
|
"b",
|
||||||
|
"strong",
|
||||||
|
"i",
|
||||||
|
"em",
|
||||||
|
"u",
|
||||||
|
"p",
|
||||||
|
"br",
|
||||||
|
"ul",
|
||||||
|
"ol",
|
||||||
|
"li",
|
||||||
|
"blockquote",
|
||||||
|
"code",
|
||||||
|
"pre",
|
||||||
|
"a",
|
||||||
|
"h1",
|
||||||
|
"h2",
|
||||||
|
"h3",
|
||||||
|
"h4",
|
||||||
|
"h5",
|
||||||
|
"h6",
|
||||||
|
}
|
||||||
|
|
||||||
|
_ALLOWED_ATTRS = {
|
||||||
|
"a": {"href", "title", "target", "rel"},
|
||||||
|
}
|
||||||
|
|
||||||
|
_ALLOWED_SCHEMES = ("http://", "https://", "mailto:")
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_attr(value: str) -> str:
|
||||||
|
return html.escape(value, quote=True)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_html_fragment(raw: str) -> str:
|
||||||
|
class SafeHtmlParser(HTMLParser):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self.parts = []
|
||||||
|
|
||||||
|
def handle_starttag(self, tag, attrs):
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag not in _ALLOWED_TAGS:
|
||||||
|
return
|
||||||
|
|
||||||
|
allowed = _ALLOWED_ATTRS.get(tag, set())
|
||||||
|
clean_attrs = []
|
||||||
|
|
||||||
|
for key, value in attrs:
|
||||||
|
key = (key or "").lower()
|
||||||
|
value = value or ""
|
||||||
|
|
||||||
|
if key not in allowed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tag == "a" and key == "href":
|
||||||
|
lowered = value.strip().lower()
|
||||||
|
if not lowered.startswith(_ALLOWED_SCHEMES):
|
||||||
|
continue
|
||||||
|
|
||||||
|
clean_attrs.append((key, value))
|
||||||
|
|
||||||
|
if tag == "a":
|
||||||
|
attr_keys = {key for key, _ in clean_attrs}
|
||||||
|
if "target" not in attr_keys:
|
||||||
|
clean_attrs.append(("target", "_blank"))
|
||||||
|
if "rel" not in attr_keys:
|
||||||
|
clean_attrs.append(("rel", "noopener noreferrer"))
|
||||||
|
|
||||||
|
attr_text = "".join(
|
||||||
|
f' {key}="{_escape_attr(value)}"'
|
||||||
|
for key, value in clean_attrs
|
||||||
|
)
|
||||||
|
self.parts.append(f"<{tag}{attr_text}>")
|
||||||
|
|
||||||
|
def handle_endtag(self, tag):
|
||||||
|
tag = tag.lower()
|
||||||
|
if tag in _ALLOWED_TAGS and tag not in {"br"}:
|
||||||
|
self.parts.append(f"</{tag}>")
|
||||||
|
|
||||||
|
def handle_data(self, data):
|
||||||
|
self.parts.append(html.escape(data))
|
||||||
|
|
||||||
|
def handle_entityref(self, name):
|
||||||
|
self.parts.append(f"&{name};")
|
||||||
|
|
||||||
|
def handle_charref(self, name):
|
||||||
|
self.parts.append(f"&#{name};")
|
||||||
|
|
||||||
|
parser = SafeHtmlParser()
|
||||||
|
parser.feed(raw or "")
|
||||||
|
parser.close()
|
||||||
|
return "".join(parser.parts)
|
||||||
2
static/js/.env
Normal file
2
static/js/.env
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
ADMIN_USERNAME=admin
|
||||||
|
ADMIN_PASSWORD=StrongPass123!
|
||||||
@ -1,8 +1,6 @@
|
|||||||
// static/js/00-config.js
|
// static/js/00-config.js
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
window.Glow = window.Glow || {};
|
window.Glow = window.Glow || {};
|
||||||
|
|
||||||
Glow.els = {
|
Glow.els = {
|
||||||
chatBox: document.getElementById("chatBox"),
|
chatBox: document.getElementById("chatBox"),
|
||||||
dmBox: document.getElementById("dmBox"),
|
dmBox: document.getElementById("dmBox"),
|
||||||
@ -41,7 +39,6 @@ Glow.els = {
|
|||||||
themeButton: document.getElementById("themeButton"),
|
themeButton: document.getElementById("themeButton"),
|
||||||
adminUsers: document.getElementById("adminUsers"),
|
adminUsers: document.getElementById("adminUsers"),
|
||||||
adminRooms: document.getElementById("adminRooms"),
|
adminRooms: document.getElementById("adminRooms"),
|
||||||
adminTeamsChannels: document.getElementById("adminTeamsChannels"),
|
|
||||||
statusToast: document.getElementById("statusToast"),
|
statusToast: document.getElementById("statusToast"),
|
||||||
blockedOverlay: document.getElementById("blockedOverlay"),
|
blockedOverlay: document.getElementById("blockedOverlay"),
|
||||||
blockedMessage: document.getElementById("blockedMessage"),
|
blockedMessage: document.getElementById("blockedMessage"),
|
||||||
@ -88,48 +85,15 @@ Glow.els = {
|
|||||||
callOverlay: document.getElementById("callOverlay"),
|
callOverlay: document.getElementById("callOverlay"),
|
||||||
callStatus: document.getElementById("callStatus"),
|
callStatus: document.getElementById("callStatus"),
|
||||||
callPeerLabel: document.getElementById("callPeerLabel"),
|
callPeerLabel: document.getElementById("callPeerLabel"),
|
||||||
callParticipants: document.getElementById("callParticipants"),
|
|
||||||
callPendingInvites: document.getElementById("callPendingInvites"),
|
|
||||||
remoteAudioContainer: document.getElementById("remoteAudioContainer"),
|
|
||||||
callStatusChip: document.getElementById("callStatusChip"),
|
|
||||||
startCallButton: document.getElementById("startCallButton"),
|
startCallButton: document.getElementById("startCallButton"),
|
||||||
retryCallButton: document.getElementById("retryCallButton"),
|
answerCallButton: document.getElementById("answerCallButton"),
|
||||||
addToCallButton: document.getElementById("addToCallButton"),
|
addToCallButton: document.getElementById("addToCallButton"),
|
||||||
muteCallButton: document.getElementById("muteCallButton"),
|
muteCallButton: document.getElementById("muteCallButton"),
|
||||||
hangupCallButton: document.getElementById("hangupCallButton"),
|
hangupCallButton: document.getElementById("hangupCallButton"),
|
||||||
endCallButton: document.getElementById("endCallButton"),
|
|
||||||
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
|
closeCallPanelButton: document.getElementById("closeCallPanelButton"),
|
||||||
|
remoteAudio: document.getElementById("remoteAudio"),
|
||||||
dmLayoutToggle: document.getElementById("dmLayoutToggle"),
|
emojiAutocomplete: document.getElementById("emojiAutocomplete")
|
||||||
emojiAutocomplete: document.getElementById("emojiAutocomplete"),
|
|
||||||
threadEmojiAutocomplete: document.getElementById("threadEmojiAutocomplete"),
|
|
||||||
settingsSavePanel: document.getElementById("settingsSavePanel"),
|
|
||||||
settingsSaveButton: document.getElementById("settingsSaveButton"),
|
|
||||||
settingsCloseButton: document.getElementById("settingsCloseButton"),
|
|
||||||
settingsUnsavedIndicator: document.getElementById("settingsUnsavedIndicator"),
|
|
||||||
avatarUploadInput: document.getElementById("avatarUploadInput"),
|
|
||||||
bannerUploadInput: document.getElementById("bannerUploadInput"),
|
|
||||||
imageCropOverlay: document.getElementById("imageCropOverlay"),
|
|
||||||
imageCropPreview: document.getElementById("imageCropPreview"),
|
|
||||||
imageCropZoom: document.getElementById("imageCropZoom"),
|
|
||||||
imageCropAuto: document.getElementById("imageCropAuto"),
|
|
||||||
imageCropApply: document.getElementById("imageCropApply"),
|
|
||||||
imageCropCancel: document.getElementById("imageCropCancel"),
|
|
||||||
adminTabRooms: document.getElementById("adminTabRooms"),
|
|
||||||
adminTabUsers: document.getElementById("adminTabUsers"),
|
|
||||||
adminTabTeams: document.getElementById("adminTabTeams"),
|
|
||||||
adminRoomsPanel: document.getElementById("adminRoomsPanel"),
|
|
||||||
adminUsersPanel: document.getElementById("adminUsersPanel"),
|
|
||||||
adminTeamsPanel: document.getElementById("adminTeamsPanel"),
|
|
||||||
callDragHandle: document.getElementById("callDragHandle"),
|
|
||||||
callSummaryStats: document.getElementById("callSummaryStats"),
|
|
||||||
callLatencyStat: document.getElementById("callLatencyStat"),
|
|
||||||
callPacketLossStat: document.getElementById("callPacketLossStat"),
|
|
||||||
callUptimeStat: document.getElementById("callUptimeStat"),
|
|
||||||
callUserCountStat: document.getElementById("callUserCountStat"),
|
|
||||||
callElapsedStat: document.getElementById("callElapsedStat")
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.state = {
|
Glow.state = {
|
||||||
currentUser: null,
|
currentUser: null,
|
||||||
csrfToken: "",
|
csrfToken: "",
|
||||||
@ -165,9 +129,6 @@ Glow.state = {
|
|||||||
pageTypeFilter: "",
|
pageTypeFilter: "",
|
||||||
activeProfile: null,
|
activeProfile: null,
|
||||||
settings: null,
|
settings: null,
|
||||||
settingsSaved: null,
|
|
||||||
settingsDraft: null,
|
|
||||||
settingsDirty: false,
|
|
||||||
pollTimer: null,
|
pollTimer: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
toastTimer: null,
|
toastTimer: null,
|
||||||
@ -187,33 +148,8 @@ Glow.state = {
|
|||||||
originalTitle: document.title,
|
originalTitle: document.title,
|
||||||
dialogResolver: null,
|
dialogResolver: null,
|
||||||
lastFocusedElement: null,
|
lastFocusedElement: null,
|
||||||
dmLayoutMode:
|
emojiSuggestions: [],
|
||||||
localStorage.getItem("glow_dm_layout_mode") === "compact"
|
emojiActiveInput: null,
|
||||||
? "compact"
|
|
||||||
: "cards",
|
|
||||||
profilePresenceBadgeVisible:
|
|
||||||
localStorage.getItem("glow_profile_presence_badge_visible") !== "false",
|
|
||||||
profileMediaDraft: {
|
|
||||||
avatarFile: null,
|
|
||||||
bannerFile: null
|
|
||||||
},
|
|
||||||
imageCrop: {
|
|
||||||
open: false,
|
|
||||||
kind: "",
|
|
||||||
file: null,
|
|
||||||
previewUrl: "",
|
|
||||||
zoom: 1,
|
|
||||||
autoCrop: false
|
|
||||||
},
|
|
||||||
emojiAutocomplete: {
|
|
||||||
open: false,
|
|
||||||
owner: "main",
|
|
||||||
query: "",
|
|
||||||
suggestions: [],
|
|
||||||
selectedIndex: 0,
|
|
||||||
triggerIndex: -1
|
|
||||||
},
|
|
||||||
adminTab: "rooms",
|
|
||||||
presence: {
|
presence: {
|
||||||
roomOnlineCount: 0,
|
roomOnlineCount: 0,
|
||||||
channelOnlineCount: 0,
|
channelOnlineCount: 0,
|
||||||
@ -228,52 +164,40 @@ Glow.state = {
|
|||||||
selfPeerId: null,
|
selfPeerId: null,
|
||||||
peerConnections: new Map(),
|
peerConnections: new Map(),
|
||||||
remoteStreams: new Map(),
|
remoteStreams: new Map(),
|
||||||
participantAudioEls: new Map(),
|
|
||||||
peers: [],
|
peers: [],
|
||||||
reconnectTimer: null,
|
reconnectTimer: null,
|
||||||
invitePollTimer: null,
|
|
||||||
participantPollTimer: null,
|
|
||||||
heartbeatTimer: null,
|
|
||||||
invites: [],
|
invites: [],
|
||||||
reconnecting: false,
|
invitePollTimer: null
|
||||||
joining: false,
|
|
||||||
leaving: false,
|
|
||||||
permissions: {
|
|
||||||
can_manage: false,
|
|
||||||
can_end: false,
|
|
||||||
can_invite: false
|
|
||||||
},
|
|
||||||
backendParticipants: [],
|
|
||||||
scope: null,
|
|
||||||
statusText: "Idle",
|
|
||||||
metrics: {
|
|
||||||
packetLoss: null,
|
|
||||||
uptime: null,
|
|
||||||
latency: null,
|
|
||||||
elapsedSeconds: 0,
|
|
||||||
userCount: 0
|
|
||||||
},
|
|
||||||
drag: {
|
|
||||||
active: false,
|
|
||||||
x: null,
|
|
||||||
y: null,
|
|
||||||
offsetX: 0,
|
|
||||||
offsetY: 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.MAX_MESSAGE_LENGTH = 1000;
|
Glow.MAX_MESSAGE_LENGTH = 1000;
|
||||||
Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024;
|
||||||
Glow.DEFAULT_ACCENT_COLOR = "#f7dce8";
|
Glow.DEFAULT_ACCENT_COLOR = "#f7dce8";
|
||||||
|
Glow.EMOJI_ALIASES = {
|
||||||
|
smile: "😄",
|
||||||
|
grin: "😁",
|
||||||
|
joy: "😂",
|
||||||
|
heart: "❤️",
|
||||||
|
thumbs_up: "👍",
|
||||||
|
fire: "🔥",
|
||||||
|
tada: "🎉",
|
||||||
|
clap: "👏",
|
||||||
|
eyes: "👀",
|
||||||
|
rocket: "🚀",
|
||||||
|
wave: "👋",
|
||||||
|
sparkles: "✨",
|
||||||
|
thinking: "🤔",
|
||||||
|
cry: "😢",
|
||||||
|
check: "✅",
|
||||||
|
bulb: "💡",
|
||||||
|
shrug: "¯\\_(ツ)_/¯"
|
||||||
|
};
|
||||||
Glow.normalizeAccentColor = value => {
|
Glow.normalizeAccentColor = value => {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const color = value.trim();
|
const color = value.trim();
|
||||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
|
||||||
return color.toLowerCase();
|
return color.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.normalizeNameColor = value => {
|
Glow.normalizeNameColor = value => {
|
||||||
if (value == null || value === "") return null;
|
if (value == null || value === "") return null;
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
@ -281,7 +205,6 @@ Glow.normalizeNameColor = value => {
|
|||||||
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
|
if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null;
|
||||||
return color.toLowerCase();
|
return color.toLowerCase();
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.hexToRgb = hex => {
|
Glow.hexToRgb = hex => {
|
||||||
const normalized = Glow.normalizeAccentColor(hex);
|
const normalized = Glow.normalizeAccentColor(hex);
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
@ -291,7 +214,6 @@ Glow.hexToRgb = hex => {
|
|||||||
b: parseInt(normalized.slice(5, 7), 16)
|
b: parseInt(normalized.slice(5, 7), 16)
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.relativeLuminance = hex => {
|
Glow.relativeLuminance = hex => {
|
||||||
const rgb = Glow.hexToRgb(hex);
|
const rgb = Glow.hexToRgb(hex);
|
||||||
if (!rgb) return 0;
|
if (!rgb) return 0;
|
||||||
@ -307,11 +229,9 @@ Glow.relativeLuminance = hex => {
|
|||||||
0.0722 * channel(rgb.b)
|
0.0722 * channel(rgb.b)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.accentTextColor = hex => {
|
Glow.accentTextColor = hex => {
|
||||||
return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4";
|
return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4";
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.applyAccentColor = value => {
|
Glow.applyAccentColor = value => {
|
||||||
const accent =
|
const accent =
|
||||||
Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR;
|
Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR;
|
||||||
@ -331,44 +251,10 @@ Glow.applyAccentColor = value => {
|
|||||||
localStorage.setItem("glow_accent_color", accent);
|
localStorage.setItem("glow_accent_color", accent);
|
||||||
Glow.updateThemeButton?.();
|
Glow.updateThemeButton?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.resetAccentColor = () => {
|
Glow.resetAccentColor = () => {
|
||||||
localStorage.removeItem("glow_accent_color");
|
localStorage.removeItem("glow_accent_color");
|
||||||
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
|
Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.loadDmLayoutPreference = () => {
|
|
||||||
const stored = localStorage.getItem("glow_dm_layout_mode");
|
|
||||||
return stored === "compact" ? "compact" : "cards";
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.saveDmLayoutPreference = mode => {
|
|
||||||
const normalized = mode === "compact" ? "compact" : "cards";
|
|
||||||
Glow.state.dmLayoutMode = normalized;
|
|
||||||
localStorage.setItem("glow_dm_layout_mode", normalized);
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.isSettingsDirty = () => Boolean(Glow.state.settingsDirty);
|
|
||||||
|
|
||||||
Glow.markSettingsDirty = () => {
|
|
||||||
Glow.state.settingsDirty = true;
|
|
||||||
Glow.syncSettingsSavePanel?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.clearSettingsDirty = () => {
|
|
||||||
Glow.state.settingsDirty = false;
|
|
||||||
Glow.syncSettingsSavePanel?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.getTimestampVisibilityMode = () => {
|
|
||||||
return Glow.state.activeDm ? "hover" : "always";
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.shouldShowMessageTimestamp = (_message = null, context = null) => {
|
|
||||||
const view = context || Glow.getTimestampVisibilityMode();
|
|
||||||
return view !== "hover";
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.roleDefaultNameColor = role => {
|
Glow.roleDefaultNameColor = role => {
|
||||||
switch (String(role || "").toLowerCase()) {
|
switch (String(role || "").toLowerCase()) {
|
||||||
case "admin":
|
case "admin":
|
||||||
@ -379,7 +265,6 @@ Glow.roleDefaultNameColor = role => {
|
|||||||
return "#f5f5f7";
|
return "#f5f5f7";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.resolveUserNameColor = userish => {
|
Glow.resolveUserNameColor = userish => {
|
||||||
const explicit =
|
const explicit =
|
||||||
Glow.normalizeNameColor(userish?.name_color) ||
|
Glow.normalizeNameColor(userish?.name_color) ||
|
||||||
@ -387,22 +272,18 @@ Glow.resolveUserNameColor = userish => {
|
|||||||
if (explicit) return explicit;
|
if (explicit) return explicit;
|
||||||
return Glow.roleDefaultNameColor(userish?.role || userish?.user_role);
|
return Glow.roleDefaultNameColor(userish?.role || userish?.user_role);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.applyUserColor = (element, userish) => {
|
Glow.applyUserColor = (element, userish) => {
|
||||||
if (!(element instanceof HTMLElement)) return;
|
if (!(element instanceof HTMLElement)) return;
|
||||||
element.style.color = Glow.resolveUserNameColor(userish);
|
element.style.color = Glow.resolveUserNameColor(userish);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.canModerate = () => {
|
Glow.canModerate = () => {
|
||||||
const user = Glow.state.currentUser;
|
const user = Glow.state.currentUser;
|
||||||
return Boolean(user && ["moderator", "admin"].includes(user.role));
|
return Boolean(user && ["moderator", "admin"].includes(user.role));
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.isAdmin = () => {
|
Glow.isAdmin = () => {
|
||||||
const user = Glow.state.currentUser;
|
const user = Glow.state.currentUser;
|
||||||
return Boolean(user && user.role === "admin");
|
return Boolean(user && user.role === "admin");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.showToast = (message, type = "") => {
|
Glow.showToast = (message, type = "") => {
|
||||||
clearTimeout(Glow.state.toastTimer);
|
clearTimeout(Glow.state.toastTimer);
|
||||||
Glow.els.statusToast.textContent = String(message);
|
Glow.els.statusToast.textContent = String(message);
|
||||||
@ -411,17 +292,14 @@ Glow.showToast = (message, type = "") => {
|
|||||||
Glow.els.statusToast.className = "status-toast";
|
Glow.els.statusToast.className = "status-toast";
|
||||||
}, 3500);
|
}, 3500);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.showAuthNotice = (message, type = "error") => {
|
Glow.showAuthNotice = (message, type = "error") => {
|
||||||
Glow.els.authNotice.textContent = String(message);
|
Glow.els.authNotice.textContent = String(message);
|
||||||
Glow.els.authNotice.className = `notice show ${type}`;
|
Glow.els.authNotice.className = `notice show ${type}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.clearAuthNotice = () => {
|
Glow.clearAuthNotice = () => {
|
||||||
Glow.els.authNotice.textContent = "";
|
Glow.els.authNotice.textContent = "";
|
||||||
Glow.els.authNotice.className = "notice";
|
Glow.els.authNotice.className = "notice";
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.showBlocked = message => {
|
Glow.showBlocked = message => {
|
||||||
Glow.els.blockedMessage.textContent = message || "Access has been blocked.";
|
Glow.els.blockedMessage.textContent = message || "Access has been blocked.";
|
||||||
Glow.els.blockedOverlay.classList.remove("hidden");
|
Glow.els.blockedOverlay.classList.remove("hidden");
|
||||||
@ -433,7 +311,6 @@ Glow.showBlocked = message => {
|
|||||||
Glow.els.attachmentButton.classList.add("disabled");
|
Glow.els.attachmentButton.classList.add("disabled");
|
||||||
clearTimeout(Glow.state.pollTimer);
|
clearTimeout(Glow.state.pollTimer);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.relativeTime = iso => {
|
Glow.relativeTime = iso => {
|
||||||
const seconds = Math.max(
|
const seconds = Math.max(
|
||||||
0,
|
0,
|
||||||
@ -447,7 +324,6 @@ Glow.relativeTime = iso => {
|
|||||||
if (hours < 24) return `${hours}h`;
|
if (hours < 24) return `${hours}h`;
|
||||||
return `${Math.floor(hours / 24)}d`;
|
return `${Math.floor(hours / 24)}d`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.formatDateLabel = iso => {
|
Glow.formatDateLabel = iso => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@ -463,7 +339,6 @@ Glow.formatDateLabel = iso => {
|
|||||||
day: "numeric"
|
day: "numeric"
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.dateKey = iso => {
|
Glow.dateKey = iso => {
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
return [
|
return [
|
||||||
@ -472,7 +347,6 @@ Glow.dateKey = iso => {
|
|||||||
String(date.getDate()).padStart(2, "0")
|
String(date.getDate()).padStart(2, "0")
|
||||||
].join("-");
|
].join("-");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.isNearBottom = () => {
|
Glow.isNearBottom = () => {
|
||||||
const activeBox =
|
const activeBox =
|
||||||
Glow.state.activeView === "feed"
|
Glow.state.activeView === "feed"
|
||||||
@ -485,90 +359,34 @@ Glow.isNearBottom = () => {
|
|||||||
if (!activeBox) return true;
|
if (!activeBox) return true;
|
||||||
return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100;
|
return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.autoResizeComposer = () => {
|
Glow.autoResizeComposer = () => {
|
||||||
const input = Glow.els.messageInput;
|
const input = Glow.els.messageInput;
|
||||||
input.style.height = "auto";
|
input.style.height = "auto";
|
||||||
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
|
input.style.height = `${Math.min(input.scrollHeight, 150)}px`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.updateMessageCount = () => {
|
Glow.updateMessageCount = () => {
|
||||||
const count = Glow.els.messageInput.value.length;
|
const count = Glow.els.messageInput.value.length;
|
||||||
Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`;
|
Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.updateDocumentTitle = () => {
|
Glow.updateDocumentTitle = () => {
|
||||||
if (!Glow.state.unreadMessages) {
|
if (!Glow.state.unreadMessages) {
|
||||||
document.title = Glow.state.originalTitle;
|
document.title = Glow.state.originalTitle;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
document.title = `(${Glow.state.unreadMessages})` + Glow.state.originalTitle;
|
document.title = `(${Glow.state.unreadMessages}) ${Glow.state.originalTitle}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.resetUnreadMessages = () => {
|
Glow.resetUnreadMessages = () => {
|
||||||
Glow.state.unreadMessages = 0;
|
Glow.state.unreadMessages = 0;
|
||||||
Glow.updateDocumentTitle();
|
Glow.updateDocumentTitle();
|
||||||
Glow.els.newMessagesBar.classList.add("hidden");
|
Glow.els.newMessagesBar.classList.add("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.showNewMessages = () => {
|
Glow.showNewMessages = () => {
|
||||||
Glow.els.newMessagesBar.classList.remove("hidden");
|
Glow.els.newMessagesBar.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.normalizeSelectedFiles = fileList => {
|
Glow.normalizeSelectedFiles = fileList => {
|
||||||
if (!fileList) return [];
|
if (!fileList) return [];
|
||||||
return [...fileList].filter(Boolean);
|
return [...fileList].filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.websiteBrandMeta = value => {
|
|
||||||
if (typeof value !== "string" || !value.trim()) {
|
|
||||||
return {
|
|
||||||
host: "",
|
|
||||||
brand: "web",
|
|
||||||
label: "website",
|
|
||||||
iconText: "â—‹"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = new URL(value);
|
|
||||||
const host = url.hostname.toLowerCase().replace(/^www\./, "");
|
|
||||||
|
|
||||||
const mappings = [
|
|
||||||
{ match: /github\.com$/, brand: "github", label: "GitHub", iconText: "gh" },
|
|
||||||
{ match: /youtube\.com$|youtu\.be$/, brand: "youtube", label: "YouTube", iconText: "yt" },
|
|
||||||
{ match: /spotify\.com$/, brand: "spotify", label: "Spotify", iconText: "sp" },
|
|
||||||
{ match: /soundcloud\.com$/, brand: "soundcloud", label: "SoundCloud", iconText: "sc" },
|
|
||||||
{ match: /twitter\.com$|x\.com$/, brand: "x", label: "X", iconText: "x" }
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const item of mappings) {
|
|
||||||
if (item.match.test(host)) {
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
brand: item.brand,
|
|
||||||
label: item.label,
|
|
||||||
iconText: item.iconText
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
host,
|
|
||||||
brand: "web",
|
|
||||||
label: "website",
|
|
||||||
iconText: "â—‹"
|
|
||||||
};
|
|
||||||
} catch (_) {
|
|
||||||
return {
|
|
||||||
host: "",
|
|
||||||
brand: "web",
|
|
||||||
label: "website",
|
|
||||||
iconText: "â—‹"
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.openDialog = ({
|
Glow.openDialog = ({
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
@ -580,12 +398,10 @@ Glow.openDialog = ({
|
|||||||
if (Glow.state.dialogResolver) {
|
if (Glow.state.dialogResolver) {
|
||||||
Glow.closeDialog(null);
|
Glow.closeDialog(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.state.lastFocusedElement = document.activeElement;
|
Glow.state.lastFocusedElement = document.activeElement;
|
||||||
Glow.els.dialogTitle.textContent = title;
|
Glow.els.dialogTitle.textContent = title;
|
||||||
Glow.els.dialogBody.replaceChildren();
|
Glow.els.dialogBody.replaceChildren();
|
||||||
Glow.els.dialogActions.replaceChildren();
|
Glow.els.dialogActions.replaceChildren();
|
||||||
|
|
||||||
if (typeof body === "string") {
|
if (typeof body === "string") {
|
||||||
const paragraph = document.createElement("p");
|
const paragraph = document.createElement("p");
|
||||||
paragraph.textContent = body;
|
paragraph.textContent = body;
|
||||||
@ -593,20 +409,17 @@ Glow.openDialog = ({
|
|||||||
} else if (body instanceof Node) {
|
} else if (body instanceof Node) {
|
||||||
Glow.els.dialogBody.append(body);
|
Glow.els.dialogBody.append(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldElements = {};
|
const fieldElements = {};
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.className = "dialog-label";
|
label.className = "dialog-label";
|
||||||
label.textContent = field.label;
|
label.textContent = field.label;
|
||||||
|
|
||||||
const isMultiline = Boolean(field.multiline);
|
const isMultiline = Boolean(field.multiline);
|
||||||
const input = document.createElement(isMultiline ? "textarea" : "input");
|
const input = document.createElement(isMultiline ? "textarea" : "input");
|
||||||
input.className = "field";
|
input.className = "field";
|
||||||
input.name = field.name;
|
input.name = field.name;
|
||||||
input.value = field.value || "";
|
input.value = field.value || "";
|
||||||
input.placeholder = field.placeholder || "";
|
input.placeholder = field.placeholder || "";
|
||||||
|
|
||||||
if (isMultiline) {
|
if (isMultiline) {
|
||||||
input.rows = field.rows || 3;
|
input.rows = field.rows || 3;
|
||||||
input.maxLength = field.maxLength || 300;
|
input.maxLength = field.maxLength || 300;
|
||||||
@ -619,26 +432,20 @@ Glow.openDialog = ({
|
|||||||
input.step = field.step || "1";
|
input.step = field.step || "1";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
label.append(input);
|
label.append(input);
|
||||||
Glow.els.dialogBody.append(label);
|
Glow.els.dialogBody.append(label);
|
||||||
fieldElements[field.name] = input;
|
fieldElements[field.name] = input;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cancel = document.createElement("button");
|
const cancel = document.createElement("button");
|
||||||
cancel.className = "button secondary";
|
cancel.className = "button secondary";
|
||||||
cancel.type = "button";
|
cancel.type = "button";
|
||||||
cancel.textContent = cancelText;
|
cancel.textContent = cancelText;
|
||||||
|
|
||||||
const confirmButton = document.createElement("button");
|
const confirmButton = document.createElement("button");
|
||||||
confirmButton.className = danger ? "danger-button" : "button";
|
confirmButton.className = danger ? "danger-button" : "button";
|
||||||
confirmButton.type = "button";
|
confirmButton.type = "button";
|
||||||
confirmButton.textContent = confirmText;
|
confirmButton.textContent = confirmText;
|
||||||
|
|
||||||
Glow.els.dialogActions.append(cancel, confirmButton);
|
Glow.els.dialogActions.append(cancel, confirmButton);
|
||||||
|
|
||||||
Glow.els.dialogOverlay.classList.remove("hidden");
|
Glow.els.dialogOverlay.classList.remove("hidden");
|
||||||
|
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
Glow.state.dialogResolver = resolve;
|
Glow.state.dialogResolver = resolve;
|
||||||
cancel.addEventListener("click", () => Glow.closeDialog(null), { once: true });
|
cancel.addEventListener("click", () => Glow.closeDialog(null), { once: true });
|
||||||
@ -659,7 +466,6 @@ Glow.openDialog = ({
|
|||||||
}, 0);
|
}, 0);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.closeDialog = (result = null) => {
|
Glow.closeDialog = (result = null) => {
|
||||||
Glow.els.dialogOverlay.classList.add("hidden");
|
Glow.els.dialogOverlay.classList.add("hidden");
|
||||||
const resolve = Glow.state.dialogResolver;
|
const resolve = Glow.state.dialogResolver;
|
||||||
|
|||||||
@ -1,8 +1,6 @@
|
|||||||
// static/js/01-api.js
|
// static/js/01-api.js
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
window.Glow = window.Glow || {};
|
window.Glow = window.Glow || {};
|
||||||
|
|
||||||
Glow.refreshCsrfToken = async () => {
|
Glow.refreshCsrfToken = async () => {
|
||||||
const response = await fetch("/api/csrf", {
|
const response = await fetch("/api/csrf", {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@ -10,15 +8,12 @@ Glow.refreshCsrfToken = async () => {
|
|||||||
Accept: "application/json"
|
Accept: "application/json"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Could not refresh security token.");
|
throw new Error("Could not refresh security token.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
Glow.state.csrfToken = data.csrf_token || "";
|
Glow.state.csrfToken = data.csrf_token || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.handleUnauthorized = () => {
|
Glow.handleUnauthorized = () => {
|
||||||
Glow.state.currentUser = null;
|
Glow.state.currentUser = null;
|
||||||
Glow.state.csrfToken = "";
|
Glow.state.csrfToken = "";
|
||||||
@ -37,9 +32,6 @@ Glow.handleUnauthorized = () => {
|
|||||||
Glow.state.pageTypeFilter = "";
|
Glow.state.pageTypeFilter = "";
|
||||||
Glow.state.activeProfile = null;
|
Glow.state.activeProfile = null;
|
||||||
Glow.state.settings = null;
|
Glow.state.settings = null;
|
||||||
Glow.state.settingsSaved = null;
|
|
||||||
Glow.state.settingsDraft = null;
|
|
||||||
Glow.state.settingsDirty = false;
|
|
||||||
Glow.state.selectedAttachment = [];
|
Glow.state.selectedAttachment = [];
|
||||||
Glow.state.selectedThreadAttachment = [];
|
Glow.state.selectedThreadAttachment = [];
|
||||||
Glow.state.typingUsers = [];
|
Glow.state.typingUsers = [];
|
||||||
@ -54,44 +46,13 @@ Glow.handleUnauthorized = () => {
|
|||||||
Glow.state.dmRequestId += 1;
|
Glow.state.dmRequestId += 1;
|
||||||
Glow.state.feedRequestId += 1;
|
Glow.state.feedRequestId += 1;
|
||||||
Glow.state.threadRequestId += 1;
|
Glow.state.threadRequestId += 1;
|
||||||
|
Glow.state.calls.activeCall = null;
|
||||||
|
Glow.state.calls.signalLastSeenId = 0;
|
||||||
|
Glow.state.calls.incomingCall = null;
|
||||||
|
Glow.state.calls.pendingOffer = null;
|
||||||
clearTimeout(Glow.state.pollTimer);
|
clearTimeout(Glow.state.pollTimer);
|
||||||
clearTimeout(Glow.state.typingTimer);
|
clearTimeout(Glow.state.typingTimer);
|
||||||
clearTimeout(Glow.state.calls.reconnectTimer);
|
clearTimeout(Glow.state.calls.pollTimer);
|
||||||
clearTimeout(Glow.state.calls.invitePollTimer);
|
|
||||||
clearTimeout(Glow.state.calls.participantPollTimer);
|
|
||||||
clearTimeout(Glow.state.calls.heartbeatTimer);
|
|
||||||
|
|
||||||
Glow.state.calls.activeCall = null;
|
|
||||||
Glow.state.calls.ws = null;
|
|
||||||
Glow.state.calls.session = null;
|
|
||||||
Glow.state.calls.localStream = null;
|
|
||||||
Glow.state.calls.muted = false;
|
|
||||||
Glow.state.calls.selfPeerId = null;
|
|
||||||
Glow.state.calls.peerConnections = new Map();
|
|
||||||
Glow.state.calls.remoteStreams = new Map();
|
|
||||||
Glow.state.calls.participantAudioEls = new Map();
|
|
||||||
Glow.state.calls.peers = [];
|
|
||||||
Glow.state.calls.reconnecting = false;
|
|
||||||
Glow.state.calls.joining = false;
|
|
||||||
Glow.state.calls.leaving = false;
|
|
||||||
Glow.state.calls.invites = [];
|
|
||||||
Glow.state.calls.backendParticipants = [];
|
|
||||||
Glow.state.calls.scope = null;
|
|
||||||
Glow.state.calls.statusText = "Idle";
|
|
||||||
Glow.state.calls.metrics = {
|
|
||||||
packetLoss: null,
|
|
||||||
uptime: null,
|
|
||||||
latency: null,
|
|
||||||
elapsedSeconds: 0,
|
|
||||||
userCount: 0
|
|
||||||
};
|
|
||||||
Glow.state.calls.permissions = {
|
|
||||||
can_manage: false,
|
|
||||||
can_end: false,
|
|
||||||
can_invite: false
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.clearMessages?.();
|
Glow.clearMessages?.();
|
||||||
Glow.clearFeed?.();
|
Glow.clearFeed?.();
|
||||||
Glow.clearDms?.();
|
Glow.clearDms?.();
|
||||||
@ -99,54 +60,41 @@ Glow.handleUnauthorized = () => {
|
|||||||
Glow.closeReactionPopup?.();
|
Glow.closeReactionPopup?.();
|
||||||
Glow.closeMediaModal?.();
|
Glow.closeMediaModal?.();
|
||||||
Glow.endLocalCallUi?.();
|
Glow.endLocalCallUi?.();
|
||||||
Glow.profileResetCropState?.();
|
|
||||||
|
|
||||||
Glow.setSelectedAttachment?.([]);
|
Glow.setSelectedAttachment?.([]);
|
||||||
Glow.setSelectedThreadAttachment?.([]);
|
Glow.setSelectedThreadAttachment?.([]);
|
||||||
|
|
||||||
Glow.els.threadBox?.replaceChildren();
|
Glow.els.threadBox?.replaceChildren();
|
||||||
Glow.els.pagesBox?.replaceChildren();
|
Glow.els.pagesBox?.replaceChildren();
|
||||||
Glow.els.pageDetailBox?.replaceChildren();
|
Glow.els.pageDetailBox?.replaceChildren();
|
||||||
Glow.els.profileBox?.replaceChildren();
|
Glow.els.profileBox?.replaceChildren();
|
||||||
|
|
||||||
if (Glow.els.teamSelect) {
|
if (Glow.els.teamSelect) {
|
||||||
Glow.els.teamSelect.replaceChildren();
|
Glow.els.teamSelect.replaceChildren();
|
||||||
Glow.els.teamSelect.disabled = true;
|
Glow.els.teamSelect.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Glow.els.roomSelect) {
|
if (Glow.els.roomSelect) {
|
||||||
Glow.els.roomSelect.replaceChildren();
|
Glow.els.roomSelect.replaceChildren();
|
||||||
Glow.els.roomSelect.disabled = true;
|
Glow.els.roomSelect.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Glow.els.typingBar) {
|
if (Glow.els.typingBar) {
|
||||||
Glow.els.typingBar.textContent = "";
|
Glow.els.typingBar.textContent = "";
|
||||||
Glow.els.typingBar.classList.add("hidden");
|
Glow.els.typingBar.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Glow.els.presenceLabel) {
|
if (Glow.els.presenceLabel) {
|
||||||
Glow.els.presenceLabel.textContent = "";
|
Glow.els.presenceLabel.textContent = "";
|
||||||
Glow.els.presenceLabel.classList.add("hidden");
|
Glow.els.presenceLabel.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.state.activeView = "feed";
|
Glow.state.activeView = "feed";
|
||||||
|
|
||||||
Glow.updateAuthUI?.();
|
Glow.updateAuthUI?.();
|
||||||
Glow.showAuthNotice?.("Your session has expired. Please sign in again.");
|
Glow.showAuthNotice?.("Your session has expired. Please sign in again.");
|
||||||
Glow.els.authOverlay.classList.remove("hidden");
|
Glow.els.authOverlay.classList.remove("hidden");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
|
Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
|
||||||
const method = (options.method || "GET").toUpperCase();
|
const method = (options.method || "GET").toUpperCase();
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
headers.set("Accept", "application/json");
|
headers.set("Accept", "application/json");
|
||||||
|
|
||||||
if (!["GET", "HEAD"].includes(method)) {
|
if (!["GET", "HEAD"].includes(method)) {
|
||||||
headers.set("X-CSRF-Token", Glow.state.csrfToken || "");
|
headers.set("X-CSRF-Token", Glow.state.csrfToken || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
let response;
|
let response;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
response = await fetch(url, {
|
response = await fetch(url, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
@ -159,29 +107,23 @@ Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
|
|||||||
error.code = "network";
|
error.code = "network";
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = {};
|
let data = {};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
data = await response.json();
|
data = await response.json();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
data = {};
|
data = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 403 && data.code === "blacklisted") {
|
if (response.status === 403 && data.code === "blacklisted") {
|
||||||
Glow.showBlocked(data.error);
|
Glow.showBlocked(data.error);
|
||||||
throw new Error(data.error || "Access blocked.");
|
throw new Error(data.error || "Access blocked.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 403 && data.code === "csrf" && retryCsrf) {
|
if (response.status === 403 && data.code === "csrf" && retryCsrf) {
|
||||||
await Glow.refreshCsrfToken();
|
await Glow.refreshCsrfToken();
|
||||||
return Glow.apiRequest(url, options, false);
|
return Glow.apiRequest(url, options, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
Glow.handleUnauthorized();
|
Glow.handleUnauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = new Error(data.error || "Request failed.");
|
const error = new Error(data.error || "Request failed.");
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
@ -191,11 +133,9 @@ Glow.apiRequest = async (url, options = {}, retryCsrf = true) => {
|
|||||||
error.data = data;
|
error.data = data;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.csrf_token) {
|
if (data.csrf_token) {
|
||||||
Glow.state.csrfToken = data.csrf_token;
|
Glow.state.csrfToken = data.csrf_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -207,10 +147,8 @@ Glow.apiUpdateMyProfile = payload =>
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiGetPublicProfile = username =>
|
Glow.apiGetPublicProfile = username =>
|
||||||
Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`);
|
Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`);
|
||||||
|
|
||||||
Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings");
|
Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings");
|
||||||
Glow.apiUpdateMySettings = payload =>
|
Glow.apiUpdateMySettings = payload =>
|
||||||
Glow.apiRequest("/api/settings", {
|
Glow.apiRequest("/api/settings", {
|
||||||
@ -219,6 +157,54 @@ Glow.apiUpdateMySettings = payload =>
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* pins / shares / bookmarks / schedule */
|
||||||
|
Glow.apiPinMessage = messageId =>
|
||||||
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
Glow.apiUnpinMessage = messageId =>
|
||||||
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
Glow.apiListPins = (scope, scopeId) =>
|
||||||
|
Glow.apiRequest(`/api/pins?scope=${encodeURIComponent(scope)}&scope_id=${encodeURIComponent(scopeId)}`);
|
||||||
|
Glow.apiCreateMessageShare = messageId =>
|
||||||
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/share`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
Glow.apiCreateAttachmentShare = attachmentId =>
|
||||||
|
Glow.apiRequest(`/api/attachments/${encodeURIComponent(attachmentId)}/share`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
Glow.apiBookmarkMessage = messageId =>
|
||||||
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
Glow.apiUnbookmarkMessage = messageId =>
|
||||||
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
Glow.apiListBookmarks = () => Glow.apiRequest("/api/bookmarks");
|
||||||
|
Glow.apiCreateScheduledMessage = payload =>
|
||||||
|
Glow.apiRequest("/api/scheduled-messages", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
Glow.apiListScheduledMessages = () => Glow.apiRequest("/api/scheduled-messages");
|
||||||
|
Glow.apiCancelScheduledMessage = id =>
|
||||||
|
Glow.apiRequest(`/api/scheduled-messages/${encodeURIComponent(id)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
|
||||||
/* teams/channels */
|
/* teams/channels */
|
||||||
Glow.apiCreateTeam = payload =>
|
Glow.apiCreateTeam = payload =>
|
||||||
Glow.apiRequest("/api/teams", {
|
Glow.apiRequest("/api/teams", {
|
||||||
@ -226,21 +212,18 @@ Glow.apiCreateTeam = payload =>
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiUpdateTeam = (teamSlug, payload) =>
|
Glow.apiUpdateTeam = (teamSlug, payload) =>
|
||||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, {
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiCreateChannel = (teamSlug, payload) =>
|
Glow.apiCreateChannel = (teamSlug, payload) =>
|
||||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, {
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
|
Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
||||||
@ -250,23 +233,19 @@ Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) =>
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Glow.apiDeleteChannel = (teamSlug, channelSlug) =>
|
Glow.apiDeleteChannel = (teamSlug, channelSlug) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
|
|
||||||
Glow.apiListTeamMembers = teamSlug =>
|
Glow.apiListTeamMembers = teamSlug =>
|
||||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`);
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`);
|
||||||
|
|
||||||
Glow.apiAddTeamMember = (teamSlug, payload) =>
|
Glow.apiAddTeamMember = (teamSlug, payload) =>
|
||||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, {
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
|
Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
||||||
@ -276,20 +255,17 @@ Glow.apiUpdateTeamMember = (teamSlug, userId, payload) =>
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
Glow.apiRemoveTeamMember = (teamSlug, userId) =>
|
Glow.apiRemoveTeamMember = (teamSlug, userId) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
`/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
|
|
||||||
Glow.apiCreateTeamInvite = (teamSlug, payload = {}) =>
|
Glow.apiCreateTeamInvite = (teamSlug, payload = {}) =>
|
||||||
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, {
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiAcceptTeamInvite = token =>
|
Glow.apiAcceptTeamInvite = token =>
|
||||||
Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, {
|
Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -304,7 +280,6 @@ Glow.apiCreateRoom = payload =>
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiUpdateRoom = (roomSlug, payload) =>
|
Glow.apiUpdateRoom = (roomSlug, payload) =>
|
||||||
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, {
|
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
@ -315,14 +290,12 @@ Glow.apiUpdateRoom = (roomSlug, payload) =>
|
|||||||
/* reactions */
|
/* reactions */
|
||||||
Glow.apiGetMessageReactions = messageId =>
|
Glow.apiGetMessageReactions = messageId =>
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`);
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`);
|
||||||
|
|
||||||
Glow.apiAddReaction = (messageId, emoji) =>
|
Glow.apiAddReaction = (messageId, emoji) =>
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, {
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ emoji })
|
body: JSON.stringify({ emoji })
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiRemoveReaction = (messageId, emoji) =>
|
Glow.apiRemoveReaction = (messageId, emoji) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`,
|
`/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`,
|
||||||
@ -336,38 +309,30 @@ Glow.apiEditMessage = (messageId, payload) =>
|
|||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiSearch = query =>
|
Glow.apiSearch = query =>
|
||||||
Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`);
|
Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`);
|
||||||
|
|
||||||
Glow.apiMarkRead = messageIds =>
|
Glow.apiMarkRead = messageIds =>
|
||||||
Glow.apiRequest("/api/read-receipts", {
|
Glow.apiRequest("/api/read-receipts", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ message_ids: messageIds })
|
body: JSON.stringify({ message_ids: messageIds })
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiGetMessageReceipts = messageId =>
|
Glow.apiGetMessageReceipts = messageId =>
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`);
|
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`);
|
||||||
|
|
||||||
Glow.apiPingPresence = payload =>
|
Glow.apiPingPresence = payload =>
|
||||||
Glow.apiRequest("/api/presence", {
|
Glow.apiRequest("/api/presence", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiGetRoomPresence = roomSlug =>
|
Glow.apiGetRoomPresence = roomSlug =>
|
||||||
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`);
|
Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`);
|
||||||
|
|
||||||
Glow.apiGetChannelPresence = (teamSlug, channelSlug) =>
|
Glow.apiGetChannelPresence = (teamSlug, channelSlug) =>
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence`
|
`/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence`
|
||||||
);
|
);
|
||||||
|
|
||||||
Glow.apiGetDmPresence = dmId =>
|
Glow.apiGetDmPresence = dmId =>
|
||||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`);
|
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`);
|
||||||
|
|
||||||
Glow.apiLoadHistory = params => {
|
Glow.apiLoadHistory = params => {
|
||||||
const qs = new URLSearchParams(params);
|
const qs = new URLSearchParams(params);
|
||||||
return Glow.apiRequest(`/api/messages/history?${qs.toString()}`);
|
return Glow.apiRequest(`/api/messages/history?${qs.toString()}`);
|
||||||
@ -375,31 +340,26 @@ Glow.apiLoadHistory = params => {
|
|||||||
|
|
||||||
/* DMs */
|
/* DMs */
|
||||||
Glow.apiListDms = () => Glow.apiRequest("/api/dms");
|
Glow.apiListDms = () => Glow.apiRequest("/api/dms");
|
||||||
|
|
||||||
Glow.apiCreateDm = payload =>
|
Glow.apiCreateDm = payload =>
|
||||||
Glow.apiRequest("/api/dms", {
|
Glow.apiRequest("/api/dms", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiGetDmMessages = (dmId, afterId = null) => {
|
Glow.apiGetDmMessages = (dmId, afterId = null) => {
|
||||||
const base = `/api/dms/${encodeURIComponent(dmId)}/messages`;
|
const base = `/api/dms/${encodeURIComponent(dmId)}/messages`;
|
||||||
return Glow.apiRequest(
|
return Glow.apiRequest(
|
||||||
afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`
|
afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.apiSendDmMessage = (dmId, payload) =>
|
Glow.apiSendDmMessage = (dmId, payload) =>
|
||||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, {
|
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiGetDmTyping = dmId =>
|
Glow.apiGetDmTyping = dmId =>
|
||||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`);
|
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`);
|
||||||
|
|
||||||
Glow.apiSetDmTyping = (dmId, typing) =>
|
Glow.apiSetDmTyping = (dmId, typing) =>
|
||||||
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, {
|
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@ -407,123 +367,28 @@ Glow.apiSetDmTyping = (dmId, typing) =>
|
|||||||
body: JSON.stringify({ typing })
|
body: JSON.stringify({ typing })
|
||||||
});
|
});
|
||||||
|
|
||||||
/* group calls */
|
/* calls */
|
||||||
Glow.apiGetGroupCall = sessionId =>
|
Glow.apiCreateCall = dmId =>
|
||||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}`);
|
Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, {
|
||||||
|
|
||||||
Glow.apiJoinGroupCall = sessionId =>
|
|
||||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/join`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
|
Glow.apiGetCall = callId =>
|
||||||
Glow.apiHeartbeatGroupCall = sessionId =>
|
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`);
|
||||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/heartbeat`, {
|
Glow.apiPostCallSignal = (callId, payload) =>
|
||||||
method: "POST",
|
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, {
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiLeaveGroupCall = sessionId =>
|
|
||||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/leave`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiEndGroupCall = sessionId =>
|
|
||||||
Glow.apiRequest(`/api/group-calls/${encodeURIComponent(sessionId)}/end`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiGetGroupCallParticipants = sessionId =>
|
|
||||||
Glow.apiRequest(
|
|
||||||
`/api/group-calls/${encodeURIComponent(sessionId)}/participants`
|
|
||||||
);
|
|
||||||
|
|
||||||
Glow.apiInviteToGroupCall = payload =>
|
|
||||||
Glow.apiRequest("/api/group-calls/invite", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
});
|
});
|
||||||
|
Glow.apiGetCallSignals = (callId, afterId = 0) =>
|
||||||
Glow.apiListGroupCallInvites = () =>
|
|
||||||
Glow.apiRequest("/api/group-calls/invites");
|
|
||||||
|
|
||||||
Glow.apiAcceptGroupCallInvite = inviteId =>
|
|
||||||
Glow.apiRequest(
|
Glow.apiRequest(
|
||||||
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/accept`,
|
`/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}`
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
Glow.apiEndCall = callId =>
|
||||||
Glow.apiDismissGroupCallInvite = inviteId =>
|
Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, {
|
||||||
Glow.apiRequest(
|
|
||||||
`/api/group-calls/invites/${encodeURIComponent(inviteId)}/dismiss`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
/* optional / not yet guaranteed on all backends */
|
|
||||||
Glow.apiBookmarkMessage = messageId =>
|
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.apiUnbookmarkMessage = messageId =>
|
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/bookmark`, {
|
|
||||||
method: "DELETE"
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiPinMessage = messageId =>
|
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiUnpinMessage = messageId =>
|
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/pin`, {
|
|
||||||
method: "DELETE"
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiListPins = (scopeKind, scopeId) =>
|
|
||||||
Glow.apiRequest(
|
|
||||||
`/api/pins?scope_kind=${encodeURIComponent(scopeKind)}&scope_id=${encodeURIComponent(scopeId)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
Glow.apiListBookmarks = () =>
|
|
||||||
Glow.apiRequest("/api/bookmarks");
|
|
||||||
|
|
||||||
Glow.apiCreateMessageShare = messageId =>
|
|
||||||
Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/share`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiCreateAttachmentShare = attachmentId =>
|
|
||||||
Glow.apiRequest(`/api/attachments/${encodeURIComponent(attachmentId)}/share`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({})
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.apiCreateScheduledMessage = payload =>
|
|
||||||
Glow.apiRequest("/api/scheduled-messages", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -6,99 +6,58 @@ window.Glow = window.Glow || {};
|
|||||||
Glow.closeAdminPanel = () => {
|
Glow.closeAdminPanel = () => {
|
||||||
Glow.els.adminPanel.classList.remove("open");
|
Glow.els.adminPanel.classList.remove("open");
|
||||||
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
|
Glow.els.adminPanel.setAttribute("aria-hidden", "true");
|
||||||
Glow.els.adminFab?.focus();
|
Glow.els.adminFab.focus();
|
||||||
};
|
|
||||||
|
|
||||||
Glow.setAdminTab = tab => {
|
|
||||||
Glow.state.adminTab = ["rooms", "users", "teams"].includes(tab) ? tab : "rooms";
|
|
||||||
|
|
||||||
const isRooms = Glow.state.adminTab === "rooms";
|
|
||||||
const isUsers = Glow.state.adminTab === "users";
|
|
||||||
const isTeams = Glow.state.adminTab === "teams";
|
|
||||||
|
|
||||||
Glow.els.adminTabRooms?.classList.toggle("active", isRooms);
|
|
||||||
Glow.els.adminTabUsers?.classList.toggle("active", isUsers);
|
|
||||||
Glow.els.adminTabTeams?.classList.toggle("active", isTeams);
|
|
||||||
|
|
||||||
Glow.els.adminTabRooms?.setAttribute("aria-selected", String(isRooms));
|
|
||||||
Glow.els.adminTabUsers?.setAttribute("aria-selected", String(isUsers));
|
|
||||||
Glow.els.adminTabTeams?.setAttribute("aria-selected", String(isTeams));
|
|
||||||
|
|
||||||
Glow.els.adminRoomsPanel?.classList.toggle("hidden", !isRooms);
|
|
||||||
Glow.els.adminUsersPanel?.classList.toggle("hidden", !isUsers);
|
|
||||||
Glow.els.adminTeamsPanel?.classList.toggle("hidden", !isTeams);
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.buildAdminRow = ({ title, subtitle, actions = [], className = "" }) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = className || "admin-room";
|
|
||||||
|
|
||||||
const details = document.createElement("div");
|
|
||||||
|
|
||||||
const name = document.createElement("div");
|
|
||||||
name.className =
|
|
||||||
className.includes("user")
|
|
||||||
? "admin-user-name"
|
|
||||||
: className.includes("team")
|
|
||||||
? "admin-team-name"
|
|
||||||
: "admin-room-name";
|
|
||||||
name.textContent = title;
|
|
||||||
|
|
||||||
const info = document.createElement("div");
|
|
||||||
info.className =
|
|
||||||
className.includes("user")
|
|
||||||
? "admin-user-info"
|
|
||||||
: className.includes("team")
|
|
||||||
? "admin-team-info"
|
|
||||||
: "admin-room-info";
|
|
||||||
info.textContent = subtitle || "";
|
|
||||||
|
|
||||||
details.append(name, info);
|
|
||||||
|
|
||||||
const actionsWrap = document.createElement("div");
|
|
||||||
actionsWrap.className =
|
|
||||||
className.includes("user")
|
|
||||||
? "admin-user-actions"
|
|
||||||
: className.includes("team")
|
|
||||||
? "admin-team-actions"
|
|
||||||
: "admin-room-actions";
|
|
||||||
|
|
||||||
for (const action of actions) {
|
|
||||||
actionsWrap.append(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
row.append(details, actionsWrap);
|
|
||||||
return row;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.renderAdminUsers = users => {
|
Glow.renderAdminUsers = users => {
|
||||||
Glow.els.adminUsers.replaceChildren();
|
Glow.els.adminUsers.replaceChildren();
|
||||||
|
|
||||||
const heading = document.createElement("h3");
|
const heading = document.createElement("h3");
|
||||||
heading.textContent = "users";
|
heading.textContent = "Users";
|
||||||
Glow.els.adminUsers.append(heading);
|
Glow.els.adminUsers.append(heading);
|
||||||
|
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "admin-empty";
|
empty.className = "admin-empty";
|
||||||
empty.textContent = "no users found.";
|
empty.textContent = "No users found.";
|
||||||
Glow.els.adminUsers.append(empty);
|
Glow.els.adminUsers.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const user of users) {
|
for (const user of users) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "admin-user";
|
||||||
|
|
||||||
|
const details = document.createElement("div");
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "admin-user-name";
|
||||||
|
name.textContent = `${user.username} · ${user.role}`;
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "admin-user-info";
|
||||||
|
info.textContent =
|
||||||
|
`${user.status} · ${user.message_count} message(s)` +
|
||||||
|
(user.restricted_until
|
||||||
|
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
|
||||||
|
: "");
|
||||||
|
|
||||||
|
details.append(name, info);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "admin-user-actions";
|
||||||
|
|
||||||
const current =
|
const current =
|
||||||
Glow.state.currentUser &&
|
Glow.state.currentUser &&
|
||||||
Number(Glow.state.currentUser.id) === Number(user.id);
|
Number(Glow.state.currentUser.id) === Number(user.id);
|
||||||
|
|
||||||
const actions = [];
|
|
||||||
|
|
||||||
if (!current && Glow.isAdmin()) {
|
if (!current && Glow.isAdmin()) {
|
||||||
const role = document.createElement("button");
|
const role = document.createElement("button");
|
||||||
role.className = "small-button";
|
role.className = "small-button";
|
||||||
role.type = "button";
|
role.type = "button";
|
||||||
role.textContent =
|
role.textContent =
|
||||||
user.role === "moderator" ? "make user" : "make moderator";
|
user.role === "moderator" ? "Make user" : "Make moderator";
|
||||||
|
|
||||||
role.addEventListener("click", () => {
|
role.addEventListener("click", () => {
|
||||||
Glow.updateAdminUser(user.id, {
|
Glow.updateAdminUser(user.id, {
|
||||||
role: user.role === "moderator" ? "user" : "moderator"
|
role: user.role === "moderator" ? "user" : "moderator"
|
||||||
@ -109,7 +68,8 @@ Glow.renderAdminUsers = users => {
|
|||||||
restrict.className = "small-button";
|
restrict.className = "small-button";
|
||||||
restrict.type = "button";
|
restrict.type = "button";
|
||||||
restrict.textContent =
|
restrict.textContent =
|
||||||
user.status === "restricted" ? "unrestrict" : "restrict";
|
user.status === "restricted" ? "Unrestrict" : "Restrict";
|
||||||
|
|
||||||
restrict.addEventListener("click", () => {
|
restrict.addEventListener("click", () => {
|
||||||
Glow.toggleRestriction(user);
|
Glow.toggleRestriction(user);
|
||||||
});
|
});
|
||||||
@ -118,26 +78,17 @@ Glow.renderAdminUsers = users => {
|
|||||||
blacklist.className = "small-button danger";
|
blacklist.className = "small-button danger";
|
||||||
blacklist.type = "button";
|
blacklist.type = "button";
|
||||||
blacklist.textContent =
|
blacklist.textContent =
|
||||||
user.status === "blacklisted" ? "unblacklist" : "blacklist";
|
user.status === "blacklisted" ? "Unblacklist" : "Blacklist";
|
||||||
|
|
||||||
blacklist.addEventListener("click", () => {
|
blacklist.addEventListener("click", () => {
|
||||||
Glow.toggleBlacklist(user);
|
Glow.toggleBlacklist(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(role, restrict, blacklist);
|
actions.append(role, restrict, blacklist);
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.els.adminUsers.append(
|
row.append(details, actions);
|
||||||
Glow.buildAdminRow({
|
Glow.els.adminUsers.append(row);
|
||||||
title: `${user.username} · ${user.role}`,
|
|
||||||
subtitle:
|
|
||||||
`${user.status} · ${user.message_count} message(s)` +
|
|
||||||
(user.restricted_until
|
|
||||||
? ` · until ${new Date(user.restricted_until).toLocaleString()}`
|
|
||||||
: ""),
|
|
||||||
actions,
|
|
||||||
className: "admin-user"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -145,33 +96,52 @@ Glow.renderAdminRooms = rooms => {
|
|||||||
Glow.els.adminRooms.replaceChildren();
|
Glow.els.adminRooms.replaceChildren();
|
||||||
|
|
||||||
const heading = document.createElement("h3");
|
const heading = document.createElement("h3");
|
||||||
heading.textContent = "rooms";
|
heading.textContent = "Rooms";
|
||||||
Glow.els.adminRooms.append(heading);
|
Glow.els.adminRooms.append(heading);
|
||||||
|
|
||||||
if (!rooms.length) {
|
if (!rooms.length) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "admin-empty";
|
empty.className = "admin-empty";
|
||||||
empty.textContent = "no rooms found.";
|
empty.textContent = "No rooms found.";
|
||||||
Glow.els.adminRooms.append(empty);
|
Glow.els.adminRooms.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
const actions = [];
|
const row = document.createElement("div");
|
||||||
|
row.className = "admin-room";
|
||||||
|
|
||||||
|
const details = document.createElement("div");
|
||||||
|
|
||||||
|
const name = document.createElement("div");
|
||||||
|
name.className = "admin-room-name";
|
||||||
|
name.textContent = `${room.name} · ${room.visibility}`;
|
||||||
|
|
||||||
|
const info = document.createElement("div");
|
||||||
|
info.className = "admin-room-info";
|
||||||
|
info.textContent =
|
||||||
|
`/${room.slug}` +
|
||||||
|
(room.description ? ` · ${room.description}` : "");
|
||||||
|
|
||||||
|
details.append(name, info);
|
||||||
|
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "admin-room-actions";
|
||||||
|
|
||||||
if (Glow.isAdmin()) {
|
if (Glow.isAdmin()) {
|
||||||
const edit = document.createElement("button");
|
const edit = document.createElement("button");
|
||||||
edit.className = "small-button";
|
edit.className = "small-button";
|
||||||
edit.type = "button";
|
edit.type = "button";
|
||||||
edit.textContent = "edit";
|
edit.textContent = "Edit";
|
||||||
edit.addEventListener("click", () => Glow.editRoom(room));
|
edit.addEventListener("click", () => Glow.editRoom(room));
|
||||||
actions.push(edit);
|
|
||||||
|
actions.append(edit);
|
||||||
|
|
||||||
if (room.visibility === "private") {
|
if (room.visibility === "private") {
|
||||||
const members = document.createElement("button");
|
const members = document.createElement("button");
|
||||||
members.className = "small-button";
|
members.className = "small-button";
|
||||||
members.type = "button";
|
members.type = "button";
|
||||||
members.textContent = "members";
|
members.textContent = "Members";
|
||||||
members.addEventListener("click", () => {
|
members.addEventListener("click", () => {
|
||||||
Glow.manageRoomMembers(room);
|
Glow.manageRoomMembers(room);
|
||||||
});
|
});
|
||||||
@ -179,139 +149,27 @@ Glow.renderAdminRooms = rooms => {
|
|||||||
const invite = document.createElement("button");
|
const invite = document.createElement("button");
|
||||||
invite.className = "small-button";
|
invite.className = "small-button";
|
||||||
invite.type = "button";
|
invite.type = "button";
|
||||||
invite.textContent = "invite link";
|
invite.textContent = "Invite link";
|
||||||
invite.addEventListener("click", () => {
|
invite.addEventListener("click", () => {
|
||||||
Glow.createInviteLink(room);
|
Glow.createInviteLink(room);
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.push(members, invite);
|
actions.append(members, invite);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (room.slug !== "general") {
|
if (room.slug !== "general") {
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.className = "small-button danger";
|
remove.className = "small-button danger";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "delete";
|
remove.textContent = "Delete";
|
||||||
remove.addEventListener("click", () => Glow.deleteRoom(room));
|
remove.addEventListener("click", () => Glow.deleteRoom(room));
|
||||||
actions.push(remove);
|
|
||||||
|
actions.append(remove);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.els.adminRooms.append(
|
row.append(details, actions);
|
||||||
Glow.buildAdminRow({
|
Glow.els.adminRooms.append(row);
|
||||||
title: `${room.name} · ${room.visibility}`,
|
|
||||||
subtitle: `/${room.slug}` + (room.description ? ` · ${room.description}` : ""),
|
|
||||||
actions,
|
|
||||||
className: "admin-room"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.renderAdminTeamsChannels = () => {
|
|
||||||
const box = Glow.els.adminTeamsChannels;
|
|
||||||
if (!box) return;
|
|
||||||
box.replaceChildren();
|
|
||||||
|
|
||||||
const heading = document.createElement("h3");
|
|
||||||
heading.textContent = "teams / channels";
|
|
||||||
box.append(heading);
|
|
||||||
|
|
||||||
const team = Glow.state.activeTeam;
|
|
||||||
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "admin-empty";
|
|
||||||
empty.textContent = "select a team to manage team or channel actions.";
|
|
||||||
box.append(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const teamActions = [];
|
|
||||||
if (team.can_manage) {
|
|
||||||
const editTeam = document.createElement("button");
|
|
||||||
editTeam.className = "small-button";
|
|
||||||
editTeam.type = "button";
|
|
||||||
editTeam.textContent = "edit team";
|
|
||||||
editTeam.addEventListener("click", () => Glow.openEditTeamDialog?.(team));
|
|
||||||
|
|
||||||
const members = document.createElement("button");
|
|
||||||
members.className = "small-button";
|
|
||||||
members.type = "button";
|
|
||||||
members.textContent = "members";
|
|
||||||
members.addEventListener("click", () => Glow.manageTeamMembers?.(team));
|
|
||||||
|
|
||||||
const invite = document.createElement("button");
|
|
||||||
invite.className = "small-button";
|
|
||||||
invite.type = "button";
|
|
||||||
invite.textContent = "invite";
|
|
||||||
invite.addEventListener("click", () => Glow.createTeamInviteLink?.(team));
|
|
||||||
|
|
||||||
const addChannel = document.createElement("button");
|
|
||||||
addChannel.className = "small-button";
|
|
||||||
addChannel.type = "button";
|
|
||||||
addChannel.textContent = "add channel";
|
|
||||||
addChannel.addEventListener("click", () => Glow.openCreateChannelDialog?.(team));
|
|
||||||
|
|
||||||
teamActions.push(editTeam, members, invite, addChannel);
|
|
||||||
}
|
|
||||||
|
|
||||||
box.append(
|
|
||||||
Glow.buildAdminRow({
|
|
||||||
title: team.name,
|
|
||||||
subtitle: team.description || `/${team.slug}`,
|
|
||||||
actions: teamActions,
|
|
||||||
className: "admin-team-row"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const channels = Array.isArray(Glow.state.channels) ? Glow.state.channels : [];
|
|
||||||
if (!channels.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "admin-empty";
|
|
||||||
empty.textContent = "no channels available for this team.";
|
|
||||||
box.append(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of channels) {
|
|
||||||
const actions = [];
|
|
||||||
const isActive = channel && item.slug === channel.slug;
|
|
||||||
|
|
||||||
if (team.can_manage) {
|
|
||||||
const edit = document.createElement("button");
|
|
||||||
edit.className = "small-button";
|
|
||||||
edit.type = "button";
|
|
||||||
edit.textContent = "edit";
|
|
||||||
edit.addEventListener("click", () => {
|
|
||||||
Glow.openEditChannelDialog?.(team, item);
|
|
||||||
});
|
|
||||||
actions.push(edit);
|
|
||||||
|
|
||||||
if (!item.legacy_room_id) {
|
|
||||||
const remove = document.createElement("button");
|
|
||||||
remove.className = "small-button danger";
|
|
||||||
remove.type = "button";
|
|
||||||
remove.textContent = "delete";
|
|
||||||
remove.addEventListener("click", async () => {
|
|
||||||
const previous = Glow.state.activeChannel;
|
|
||||||
Glow.state.activeChannel = item;
|
|
||||||
await Glow.deleteCurrentChannel?.(team);
|
|
||||||
Glow.state.activeChannel = previous;
|
|
||||||
});
|
|
||||||
actions.push(remove);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
box.append(
|
|
||||||
Glow.buildAdminRow({
|
|
||||||
title: `${isActive ? "• " : ""}# ${item.name}`,
|
|
||||||
subtitle: item.description || `/${item.slug}`,
|
|
||||||
actions,
|
|
||||||
className: "admin-team-row"
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -321,15 +179,8 @@ Glow.openAdminPanel = async () => {
|
|||||||
Glow.els.adminPanel.classList.add("open");
|
Glow.els.adminPanel.classList.add("open");
|
||||||
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
|
Glow.els.adminPanel.setAttribute("aria-hidden", "false");
|
||||||
|
|
||||||
if (Glow.els.adminUsers) {
|
Glow.els.adminUsers.textContent = "Loading users…";
|
||||||
Glow.els.adminUsers.textContent = "loading users…";
|
Glow.els.adminRooms.textContent = "Loading rooms…";
|
||||||
}
|
|
||||||
if (Glow.els.adminRooms) {
|
|
||||||
Glow.els.adminRooms.textContent = "loading rooms…";
|
|
||||||
}
|
|
||||||
if (Glow.els.adminTeamsChannels) {
|
|
||||||
Glow.els.adminTeamsChannels.textContent = "loading teams/channels…";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [usersData, roomsData] = await Promise.all([
|
const [usersData, roomsData] = await Promise.all([
|
||||||
@ -339,15 +190,134 @@ Glow.openAdminPanel = async () => {
|
|||||||
|
|
||||||
Glow.renderAdminUsers(usersData.users || []);
|
Glow.renderAdminUsers(usersData.users || []);
|
||||||
Glow.renderAdminRooms(roomsData.rooms || []);
|
Glow.renderAdminRooms(roomsData.rooms || []);
|
||||||
Glow.renderAdminTeamsChannels();
|
Glow.updateAdminPreferenceButtons();
|
||||||
Glow.setAdminTab(Glow.state.adminTab || "rooms");
|
|
||||||
|
|
||||||
setTimeout(() => Glow.els.closeAdminButton?.focus(), 0);
|
setTimeout(() => Glow.els.closeAdminButton.focus(), 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not load moderation data.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.updateThemeButton = () => {
|
||||||
|
if (!Glow.els.themeButton) return;
|
||||||
|
Glow.els.themeButton.textContent = "Accent color";
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.updateAdminPreferenceButtons = () => {
|
||||||
|
Glow.els.typingToggleButton.textContent =
|
||||||
|
`Typing indicators: ${Glow.state.typingIndicatorsEnabled ? "on" : "off"}`;
|
||||||
|
|
||||||
|
Glow.els.notificationsToggleButton.textContent =
|
||||||
|
`Notifications: ${Glow.state.notificationsEnabled ? "on" : "off"}`;
|
||||||
|
|
||||||
|
Glow.updateThemeButton();
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.openAccentColorPicker = async () => {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "accent-picker";
|
||||||
|
|
||||||
|
const description = document.createElement("p");
|
||||||
|
description.className = "accent-picker-description";
|
||||||
|
description.textContent =
|
||||||
|
"Choose an accent color for focus rings, buttons, selected controls, and highlighted mentions. It is stored only in this browser unless saved in account settings.";
|
||||||
|
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "accent-picker-label";
|
||||||
|
label.textContent = "Accent color";
|
||||||
|
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "accent-picker-row";
|
||||||
|
|
||||||
|
const colorInput = document.createElement("input");
|
||||||
|
colorInput.className = "accent-color-input";
|
||||||
|
colorInput.type = "color";
|
||||||
|
colorInput.value =
|
||||||
|
Glow.normalizeAccentColor(Glow.state.accentColor) ||
|
||||||
|
Glow.DEFAULT_ACCENT_COLOR;
|
||||||
|
colorInput.setAttribute("aria-label", "Choose accent color");
|
||||||
|
|
||||||
|
const hexInput = document.createElement("input");
|
||||||
|
hexInput.className = "field accent-hex-input";
|
||||||
|
hexInput.type = "text";
|
||||||
|
hexInput.value = colorInput.value;
|
||||||
|
hexInput.maxLength = 7;
|
||||||
|
hexInput.autocomplete = "off";
|
||||||
|
hexInput.autocapitalize = "off";
|
||||||
|
hexInput.spellcheck = false;
|
||||||
|
hexInput.placeholder = "#f7dce8";
|
||||||
|
hexInput.setAttribute(
|
||||||
|
"aria-label",
|
||||||
|
"Accent color hexadecimal value"
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = document.createElement("span");
|
||||||
|
preview.className = "accent-preview";
|
||||||
|
preview.style.background = colorInput.value;
|
||||||
|
preview.setAttribute("aria-hidden", "true");
|
||||||
|
|
||||||
|
const error = document.createElement("div");
|
||||||
|
error.className = "accent-picker-error";
|
||||||
|
error.setAttribute("role", "status");
|
||||||
|
|
||||||
|
const updatePreview = value => {
|
||||||
|
const normalized = Glow.normalizeAccentColor(value);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
preview.style.background = "transparent";
|
||||||
|
error.textContent =
|
||||||
|
"Use a six-digit hex color, such as #7dd3fc.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
preview.style.background = normalized;
|
||||||
|
error.textContent = "";
|
||||||
|
|
||||||
|
if (colorInput.value !== normalized) {
|
||||||
|
colorInput.value = normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
colorInput.addEventListener("input", () => {
|
||||||
|
const color = Glow.normalizeAccentColor(colorInput.value);
|
||||||
|
if (!color) return;
|
||||||
|
hexInput.value = color;
|
||||||
|
updatePreview(color);
|
||||||
|
});
|
||||||
|
|
||||||
|
hexInput.addEventListener("input", () => {
|
||||||
|
updatePreview(hexInput.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
|
row.append(colorInput, hexInput, preview);
|
||||||
|
label.append(row);
|
||||||
|
container.append(description, label, error);
|
||||||
|
|
||||||
|
const result = await Glow.openDialog({
|
||||||
|
title: "Accent color",
|
||||||
|
body: container,
|
||||||
|
confirmText: "Save color",
|
||||||
|
cancelText: "Cancel"
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
const accent = Glow.normalizeAccentColor(hexInput.value);
|
||||||
|
|
||||||
|
if (!accent) {
|
||||||
|
Glow.showToast(
|
||||||
|
"Choose a valid six-digit hexadecimal color.",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Glow.applyAccentColor(accent);
|
||||||
|
Glow.showToast("Accent color updated.");
|
||||||
|
};
|
||||||
|
|
||||||
Glow.toggleRestriction = async user => {
|
Glow.toggleRestriction = async user => {
|
||||||
if (user.status === "restricted") {
|
if (user.status === "restricted") {
|
||||||
return Glow.updateAdminUser(user.id, {
|
return Glow.updateAdminUser(user.id, {
|
||||||
@ -357,14 +327,14 @@ Glow.toggleRestriction = async user => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: `restrict ${user.username}`,
|
title: `Restrict ${user.username}`,
|
||||||
body: "enter the restriction duration in minutes.",
|
body: "Enter the restriction duration in minutes.",
|
||||||
confirmText: "restrict",
|
confirmText: "Restrict",
|
||||||
danger: true,
|
danger: true,
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "minutes",
|
name: "minutes",
|
||||||
label: "duration in minutes",
|
label: "Duration in minutes",
|
||||||
type: "number",
|
type: "number",
|
||||||
value: "60",
|
value: "60",
|
||||||
maxLength: 5
|
maxLength: 5
|
||||||
@ -375,14 +345,20 @@ Glow.toggleRestriction = async user => {
|
|||||||
if (!values) return;
|
if (!values) return;
|
||||||
|
|
||||||
const minutes = Number(values.minutes);
|
const minutes = Number(values.minutes);
|
||||||
|
|
||||||
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
|
if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) {
|
||||||
Glow.showToast("enter a duration between 1 minute and 30 days.", "error");
|
Glow.showToast(
|
||||||
|
"Enter a duration between 1 minute and 30 days.",
|
||||||
|
"error"
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Glow.updateAdminUser(user.id, {
|
await Glow.updateAdminUser(user.id, {
|
||||||
status: "restricted",
|
status: "restricted",
|
||||||
restricted_until: new Date(Date.now() + minutes * 60 * 1000).toISOString()
|
restricted_until: new Date(
|
||||||
|
Date.now() + minutes * 60 * 1000
|
||||||
|
).toISOString()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -391,12 +367,12 @@ Glow.toggleBlacklist = async user => {
|
|||||||
|
|
||||||
const result = await Glow.openDialog({
|
const result = await Glow.openDialog({
|
||||||
title: blacklisting
|
title: blacklisting
|
||||||
? `blacklist ${user.username}?`
|
? `Blacklist ${user.username}?`
|
||||||
: `unblacklist ${user.username}?`,
|
: `Unblacklist ${user.username}?`,
|
||||||
body: blacklisting
|
body: blacklisting
|
||||||
? "the account will be denied access immediately."
|
? "The account will be denied access immediately."
|
||||||
: "the account will be restored to active status.",
|
: "The account will be restored to active status.",
|
||||||
confirmText: blacklisting ? "blacklist" : "unblacklist",
|
confirmText: blacklisting ? "Blacklist" : "Unblacklist",
|
||||||
danger: blacklisting
|
danger: blacklisting
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -415,36 +391,36 @@ Glow.updateAdminUser = async (userId, changes) => {
|
|||||||
body: JSON.stringify(changes)
|
body: JSON.stringify(changes)
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.showToast("user updated.");
|
Glow.showToast("User updated.");
|
||||||
await Glow.openAdminPanel();
|
await Glow.openAdminPanel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not update user.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.createRoom = async () => {
|
Glow.createRoom = async () => {
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: "create room",
|
title: "Create room",
|
||||||
body: "choose a room name, description, and visibility.",
|
body: "Choose a room name, description, and visibility.",
|
||||||
confirmText: "create",
|
confirmText: "Create",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
label: "room name",
|
label: "Room name",
|
||||||
placeholder: "project chat",
|
placeholder: "Project chat",
|
||||||
maxLength: 60
|
maxLength: 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
label: "description",
|
label: "Description",
|
||||||
placeholder: "optional room description",
|
placeholder: "Optional room description",
|
||||||
multiline: true,
|
multiline: true,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
maxLength: 300
|
maxLength: 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "visibility",
|
name: "visibility",
|
||||||
label: "visibility: public or private",
|
label: "Visibility: public or private",
|
||||||
value: "public",
|
value: "public",
|
||||||
maxLength: 7
|
maxLength: 7
|
||||||
}
|
}
|
||||||
@ -453,41 +429,46 @@ Glow.createRoom = async () => {
|
|||||||
|
|
||||||
if (!values) return;
|
if (!values) return;
|
||||||
|
|
||||||
const visibility = String(values.visibility || "").toLowerCase();
|
const visibility = values.visibility.toLowerCase();
|
||||||
|
|
||||||
if (!["public", "private"].includes(visibility)) {
|
if (!["public", "private"].includes(visibility)) {
|
||||||
Glow.showToast("visibility must be public or private.", "error");
|
Glow.showToast("Visibility must be public or private.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Glow.apiCreateRoom({
|
await Glow.apiRequest("/api/rooms", {
|
||||||
name: values.name,
|
method: "POST",
|
||||||
description: values.description,
|
headers: { "Content-Type": "application/json" },
|
||||||
visibility
|
body: JSON.stringify({
|
||||||
|
name: values.name,
|
||||||
|
description: values.description,
|
||||||
|
visibility
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.showToast("room created.");
|
Glow.showToast("Room created.");
|
||||||
await Glow.loadRooms();
|
await Glow.loadRooms();
|
||||||
await Glow.openAdminPanel();
|
await Glow.openAdminPanel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not create room.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.editRoom = async room => {
|
Glow.editRoom = async room => {
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: `edit ${room.name}`,
|
title: `Edit ${room.name}`,
|
||||||
confirmText: "save",
|
confirmText: "Save",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "name",
|
name: "name",
|
||||||
label: "room name",
|
label: "Room name",
|
||||||
value: room.name,
|
value: room.name,
|
||||||
maxLength: 60
|
maxLength: 60
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "description",
|
name: "description",
|
||||||
label: "description",
|
label: "Description",
|
||||||
value: room.description || "",
|
value: room.description || "",
|
||||||
multiline: true,
|
multiline: true,
|
||||||
rows: 3,
|
rows: 3,
|
||||||
@ -495,7 +476,7 @@ Glow.editRoom = async room => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "visibility",
|
name: "visibility",
|
||||||
label: "visibility: public or private",
|
label: "Visibility: public or private",
|
||||||
value: room.visibility,
|
value: room.visibility,
|
||||||
maxLength: 7
|
maxLength: 7
|
||||||
}
|
}
|
||||||
@ -504,9 +485,10 @@ Glow.editRoom = async room => {
|
|||||||
|
|
||||||
if (!values) return;
|
if (!values) return;
|
||||||
|
|
||||||
const visibility = String(values.visibility || "").toLowerCase();
|
const visibility = values.visibility.toLowerCase();
|
||||||
|
|
||||||
if (!["public", "private"].includes(visibility)) {
|
if (!["public", "private"].includes(visibility)) {
|
||||||
Glow.showToast("visibility must be public or private.", "error");
|
Glow.showToast("Visibility must be public or private.", "error");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -519,8 +501,13 @@ Glow.editRoom = async room => {
|
|||||||
|
|
||||||
Glow.updateRoom = async (slug, changes) => {
|
Glow.updateRoom = async (slug, changes) => {
|
||||||
try {
|
try {
|
||||||
await Glow.apiUpdateRoom(slug, changes);
|
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, {
|
||||||
Glow.showToast("room updated.");
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(changes)
|
||||||
|
});
|
||||||
|
|
||||||
|
Glow.showToast("Room updated.");
|
||||||
await Glow.loadRooms();
|
await Glow.loadRooms();
|
||||||
|
|
||||||
if (!Glow.state.activeDm) {
|
if (!Glow.state.activeDm) {
|
||||||
@ -529,15 +516,15 @@ Glow.updateRoom = async (slug, changes) => {
|
|||||||
|
|
||||||
await Glow.openAdminPanel();
|
await Glow.openAdminPanel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not update room.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.deleteRoom = async room => {
|
Glow.deleteRoom = async room => {
|
||||||
const result = await Glow.openDialog({
|
const result = await Glow.openDialog({
|
||||||
title: `delete ${room.name}?`,
|
title: `Delete ${room.name}?`,
|
||||||
body: "this permanently deletes the room and every message in it.",
|
body: "This permanently deletes the room and every message in it.",
|
||||||
confirmText: "delete room",
|
confirmText: "Delete room",
|
||||||
danger: true
|
danger: true
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -548,7 +535,7 @@ Glow.deleteRoom = async room => {
|
|||||||
method: "DELETE"
|
method: "DELETE"
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.showToast("room deleted.");
|
Glow.showToast("Room deleted.");
|
||||||
await Glow.loadRooms();
|
await Glow.loadRooms();
|
||||||
|
|
||||||
if (!Glow.state.activeDm) {
|
if (!Glow.state.activeDm) {
|
||||||
@ -557,39 +544,43 @@ Glow.deleteRoom = async room => {
|
|||||||
|
|
||||||
await Glow.openAdminPanel();
|
await Glow.openAdminPanel();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not delete room.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.clearActiveRoom = async () => {
|
Glow.clearActiveRoom = async () => {
|
||||||
const room = Glow.state.activeRoom;
|
const room = Glow.state.activeRoom;
|
||||||
|
|
||||||
if (!room || Glow.state.activeDm) return;
|
if (!room || Glow.state.activeDm) return;
|
||||||
|
|
||||||
const result = await Glow.openDialog({
|
const result = await Glow.openDialog({
|
||||||
title: `clear ${room.name}?`,
|
title: `Clear ${room.name}?`,
|
||||||
body: "all messages in this room will be permanently deleted.",
|
body: "All messages in this room will be permanently deleted.",
|
||||||
confirmText: "clear room",
|
confirmText: "Clear room",
|
||||||
danger: true
|
danger: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/messages`, {
|
await Glow.apiRequest(
|
||||||
method: "DELETE"
|
`/api/rooms/${encodeURIComponent(room.slug)}/messages`,
|
||||||
});
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
|
||||||
Glow.clearMessages();
|
Glow.clearMessages();
|
||||||
Glow.renderEmptyState();
|
Glow.renderEmptyState();
|
||||||
Glow.showToast("room messages cleared.");
|
Glow.showToast("Room messages cleared.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not clear room.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.manageRoomMembers = async room => {
|
Glow.manageRoomMembers = async room => {
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`);
|
const data = await Glow.apiRequest(
|
||||||
|
`/api/rooms/${encodeURIComponent(room.slug)}/members`
|
||||||
|
);
|
||||||
|
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.className = "member-list";
|
container.className = "member-list";
|
||||||
@ -604,7 +595,7 @@ Glow.manageRoomMembers = async room => {
|
|||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.className = "small-button danger";
|
remove.className = "small-button danger";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "remove";
|
remove.textContent = "Remove";
|
||||||
remove.disabled = member.membership_role === "owner";
|
remove.disabled = member.membership_role === "owner";
|
||||||
|
|
||||||
remove.addEventListener("click", async () => {
|
remove.addEventListener("click", async () => {
|
||||||
@ -613,11 +604,12 @@ Glow.manageRoomMembers = async room => {
|
|||||||
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
|
`/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
Glow.showToast("member removed.");
|
|
||||||
|
Glow.showToast("Member removed.");
|
||||||
Glow.closeDialog({});
|
Glow.closeDialog({});
|
||||||
Glow.manageRoomMembers(room);
|
Glow.manageRoomMembers(room);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not remove member.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -628,11 +620,11 @@ Glow.manageRoomMembers = async room => {
|
|||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: `${room.name} members`,
|
title: `${room.name} members`,
|
||||||
body: container,
|
body: container,
|
||||||
confirmText: "add member",
|
confirmText: "Add member",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "username",
|
name: "username",
|
||||||
label: "username to add",
|
label: "Username to add",
|
||||||
placeholder: "username",
|
placeholder: "username",
|
||||||
maxLength: 30
|
maxLength: 30
|
||||||
}
|
}
|
||||||
@ -641,37 +633,46 @@ Glow.manageRoomMembers = async room => {
|
|||||||
|
|
||||||
if (!values?.username) return;
|
if (!values?.username) return;
|
||||||
|
|
||||||
await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/members`, {
|
await Glow.apiRequest(
|
||||||
method: "POST",
|
`/api/rooms/${encodeURIComponent(room.slug)}/members`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({ username: values.username })
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username: values.username })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
Glow.showToast("member added.");
|
Glow.showToast("Member added.");
|
||||||
await Glow.manageRoomMembers(room);
|
await Glow.manageRoomMembers(room);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not manage room members.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.createInviteLink = async room => {
|
Glow.createInviteLink = async room => {
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}/invites`, {
|
const data = await Glow.apiRequest(
|
||||||
method: "POST",
|
`/api/rooms/${encodeURIComponent(room.slug)}/invites`,
|
||||||
headers: { "Content-Type": "application/json" },
|
{
|
||||||
body: JSON.stringify({})
|
method: "POST",
|
||||||
});
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const url = new URL(data.invite.url, window.location.origin).toString();
|
const url = new URL(
|
||||||
|
data.invite.url,
|
||||||
|
window.location.origin
|
||||||
|
).toString();
|
||||||
|
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: "private-room invite link",
|
title: "Private-room invite link",
|
||||||
body: "copy and share this link. it can be used once before it expires.",
|
body: "Copy and share this link. It can be used once before it expires.",
|
||||||
confirmText: "copy link",
|
confirmText: "Copy link",
|
||||||
fields: [
|
fields: [
|
||||||
{
|
{
|
||||||
name: "invite",
|
name: "invite",
|
||||||
label: "invite link",
|
label: "Invite link",
|
||||||
value: url,
|
value: url,
|
||||||
maxLength: 500
|
maxLength: 500
|
||||||
}
|
}
|
||||||
@ -680,21 +681,9 @@ Glow.createInviteLink = async room => {
|
|||||||
|
|
||||||
if (values) {
|
if (values) {
|
||||||
await navigator.clipboard.writeText(url);
|
await navigator.clipboard.writeText(url);
|
||||||
Glow.showToast("invite link copied.");
|
Glow.showToast("Invite link copied.");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not create invite link.", "error");
|
Glow.showToast(error.message, "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.els.adminTabRooms?.addEventListener("click", () => {
|
|
||||||
Glow.setAdminTab("rooms");
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.adminTabUsers?.addEventListener("click", () => {
|
|
||||||
Glow.setAdminTab("users");
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.adminTabTeams?.addEventListener("click", () => {
|
|
||||||
Glow.setAdminTab("teams");
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -99,36 +99,14 @@ Glow.openFeedEntry = async entry => {
|
|||||||
Glow.showToast("This conversation is no longer available.", "error");
|
Glow.showToast("This conversation is no longer available.", "error");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.feedAttachmentSummary = entry => {
|
|
||||||
const attachments = Array.isArray(entry.attachments)
|
|
||||||
? entry.attachments
|
|
||||||
: entry.attachment
|
|
||||||
? [entry.attachment]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!attachments.length) return "";
|
|
||||||
|
|
||||||
if (attachments.length > 1) {
|
|
||||||
return `${attachments.length} attachments`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = attachments[0];
|
|
||||||
const mime = String(item?.mime_type || "").toLowerCase();
|
|
||||||
|
|
||||||
if (mime.startsWith("image/")) return "image attachment";
|
|
||||||
if (mime.startsWith("audio/")) return "audio attachment";
|
|
||||||
if (mime.startsWith("video/")) return "video attachment";
|
|
||||||
return "file attachment";
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.buildFeedReactions = entry => {
|
Glow.buildFeedReactions = entry => {
|
||||||
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
|
const reactions = Array.isArray(entry.reactions) ? entry.reactions : [];
|
||||||
if (!reactions.length) return null;
|
if (!reactions.length) return null;
|
||||||
|
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "feed-entry-reactions compact";
|
row.className = "feed-entry-reactions";
|
||||||
|
|
||||||
for (const reaction of reactions.slice(0, 6)) {
|
for (const reaction of reactions) {
|
||||||
const chip = document.createElement("button");
|
const chip = document.createElement("button");
|
||||||
chip.className = "reaction-chip";
|
chip.className = "reaction-chip";
|
||||||
chip.type = "button";
|
chip.type = "button";
|
||||||
@ -158,35 +136,97 @@ Glow.buildFeedReactions = entry => {
|
|||||||
return row;
|
return row;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.buildFeedAttachmentBlock = entry => {
|
||||||
|
const attachments = Array.isArray(entry.attachments)
|
||||||
|
? entry.attachments
|
||||||
|
: entry.attachment
|
||||||
|
? [entry.attachment]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!attachments.length) return null;
|
||||||
|
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "message-attachments-list";
|
||||||
|
|
||||||
|
for (const attachment of attachments) {
|
||||||
|
if (String(attachment.mime_type || "").startsWith("image/")) {
|
||||||
|
const imageButton = document.createElement("button");
|
||||||
|
imageButton.className = "message-image-link";
|
||||||
|
imageButton.type = "button";
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.className = "message-image-preview";
|
||||||
|
image.src = attachment.url;
|
||||||
|
image.alt = attachment.original_name || "Image attachment";
|
||||||
|
image.loading = "lazy";
|
||||||
|
|
||||||
|
imageButton.append(image);
|
||||||
|
imageButton.addEventListener("click", () => {
|
||||||
|
Glow.openMediaModal?.({
|
||||||
|
url: attachment.url,
|
||||||
|
name: attachment.original_name,
|
||||||
|
mimeType: attachment.mime_type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wrap.append(imageButton);
|
||||||
|
} else if (String(attachment.mime_type || "").startsWith("audio/")) {
|
||||||
|
const audio = document.createElement("audio");
|
||||||
|
audio.controls = true;
|
||||||
|
audio.preload = "metadata";
|
||||||
|
audio.src = attachment.url;
|
||||||
|
wrap.append(audio);
|
||||||
|
} else if (String(attachment.mime_type || "").startsWith("video/")) {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.controls = true;
|
||||||
|
video.preload = "metadata";
|
||||||
|
video.src = attachment.url;
|
||||||
|
video.className = "message-video-preview";
|
||||||
|
wrap.append(video);
|
||||||
|
}
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "message-attachment";
|
||||||
|
link.href = attachment.url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noopener noreferrer";
|
||||||
|
link.download = attachment.original_name;
|
||||||
|
link.textContent = `Attachment: ${attachment.original_name}`;
|
||||||
|
wrap.append(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
};
|
||||||
|
|
||||||
Glow.buildFeedEntry = entry => {
|
Glow.buildFeedEntry = entry => {
|
||||||
const row = document.createElement("article");
|
const card = document.createElement("article");
|
||||||
row.className = "feed-entry timeline-row";
|
card.className = "feed-entry";
|
||||||
|
|
||||||
if (entry.is_mention) {
|
if (entry.is_mention) {
|
||||||
row.classList.add("mention");
|
card.classList.add("mention");
|
||||||
}
|
}
|
||||||
|
|
||||||
row.dataset.messageId = String(entry.id);
|
card.dataset.messageId = String(entry.id);
|
||||||
|
|
||||||
if (entry.room_slug) row.dataset.roomSlug = entry.room_slug;
|
if (entry.room_slug) card.dataset.roomSlug = entry.room_slug;
|
||||||
if (entry.team_slug) row.dataset.teamSlug = entry.team_slug;
|
if (entry.team_slug) card.dataset.teamSlug = entry.team_slug;
|
||||||
if (entry.channel_slug) row.dataset.channelSlug = entry.channel_slug;
|
if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug;
|
||||||
|
|
||||||
const top = document.createElement("div");
|
const heading = document.createElement("div");
|
||||||
top.className = "feed-entry-top";
|
heading.className = "feed-entry-heading";
|
||||||
|
|
||||||
const context = document.createElement("button");
|
const room = document.createElement("button");
|
||||||
context.className = "feed-room-link";
|
room.className = "feed-room-link";
|
||||||
context.type = "button";
|
room.type = "button";
|
||||||
|
|
||||||
if (entry.team_name && entry.channel_name) {
|
if (entry.team_name && entry.channel_name) {
|
||||||
context.textContent = `${entry.team_name} · # ${entry.channel_name}`;
|
room.textContent = `${entry.team_name} · # ${entry.channel_name}`;
|
||||||
} else {
|
} else {
|
||||||
context.textContent =
|
room.textContent =
|
||||||
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
|
`${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
context.addEventListener("click", () => {
|
room.addEventListener("click", () => {
|
||||||
Glow.openFeedEntry(entry);
|
Glow.openFeedEntry(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -196,7 +236,7 @@ Glow.buildFeedEntry = entry => {
|
|||||||
time.textContent = Glow.relativeTime(entry.created_at);
|
time.textContent = Glow.relativeTime(entry.created_at);
|
||||||
time.title = new Date(entry.created_at).toLocaleString();
|
time.title = new Date(entry.created_at).toLocaleString();
|
||||||
|
|
||||||
top.append(context, time);
|
heading.append(room, time);
|
||||||
|
|
||||||
const author = document.createElement("button");
|
const author = document.createElement("button");
|
||||||
author.className = "feed-entry-author profile-link";
|
author.className = "feed-entry-author profile-link";
|
||||||
@ -208,59 +248,37 @@ Glow.buildFeedEntry = entry => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const body = document.createElement("div");
|
const body = document.createElement("div");
|
||||||
body.className = "feed-entry-body clamp-2";
|
body.className = "feed-entry-body";
|
||||||
body.textContent = entry.text || "";
|
body.textContent = entry.text;
|
||||||
|
|
||||||
const footer = document.createElement("div");
|
|
||||||
footer.className = "feed-entry-footer";
|
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
|
||||||
meta.className = "feed-entry-meta";
|
|
||||||
|
|
||||||
const attachmentSummary = Glow.feedAttachmentSummary(entry);
|
|
||||||
if (attachmentSummary) {
|
|
||||||
const attachmentButton = document.createElement("button");
|
|
||||||
attachmentButton.className = "small-button feed-attachment-summary";
|
|
||||||
attachmentButton.type = "button";
|
|
||||||
attachmentButton.textContent = attachmentSummary;
|
|
||||||
attachmentButton.addEventListener("click", () => {
|
|
||||||
Glow.openFeedEntry(entry);
|
|
||||||
});
|
|
||||||
meta.append(attachmentButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const attachmentBlock = Glow.buildFeedAttachmentBlock(entry);
|
||||||
const reactions = Glow.buildFeedReactions(entry);
|
const reactions = Glow.buildFeedReactions(entry);
|
||||||
if (reactions) {
|
|
||||||
meta.append(reactions);
|
|
||||||
}
|
|
||||||
|
|
||||||
const openButton = document.createElement("button");
|
const openButton = document.createElement("button");
|
||||||
openButton.className = "small-button feed-open-room";
|
openButton.className = "small-button feed-open-room";
|
||||||
openButton.type = "button";
|
openButton.type = "button";
|
||||||
openButton.textContent = "Open";
|
openButton.textContent = "Open channel";
|
||||||
openButton.addEventListener("click", () => {
|
openButton.addEventListener("click", () => {
|
||||||
Glow.openFeedEntry(entry);
|
Glow.openFeedEntry(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
footer.append(meta, openButton);
|
card.addEventListener("dblclick", () => {
|
||||||
|
|
||||||
row.addEventListener("dblclick", () => {
|
|
||||||
Glow.openFeedEntry(entry);
|
Glow.openFeedEntry(entry);
|
||||||
});
|
});
|
||||||
|
|
||||||
row.addEventListener("click", event => {
|
card.append(heading, author, body);
|
||||||
if (
|
|
||||||
event.target instanceof HTMLElement &&
|
|
||||||
(event.target.closest("button") || event.target.closest("a"))
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
Glow.openFeedEntry(entry);
|
|
||||||
});
|
|
||||||
|
|
||||||
row.append(top, author, body, footer);
|
if (attachmentBlock) {
|
||||||
|
card.append(attachmentBlock);
|
||||||
|
}
|
||||||
|
|
||||||
return row;
|
if (reactions) {
|
||||||
|
card.append(reactions);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.append(openButton);
|
||||||
|
|
||||||
|
return card;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.appendFeedEntries = (entries, forceScroll = false) => {
|
Glow.appendFeedEntries = (entries, forceScroll = false) => {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
// static/js/06-pages.js
|
// static/js/06-pages.js
|
||||||
"use strict";
|
"use strict";
|
||||||
window.Glow = window.Glow || {};
|
window.Glow = window.Glow || {};
|
||||||
// comments, version history, and share links intentionally disabled in production until backend support complete
|
|
||||||
|
|
||||||
Glow.renderPagesEmptyState = message => {
|
Glow.renderPagesEmptyState = message => {
|
||||||
Glow.els.pagesBox.replaceChildren();
|
Glow.els.pagesBox.replaceChildren();
|
||||||
@ -16,21 +15,49 @@ Glow.renderPagesEmptyState = message => {
|
|||||||
|
|
||||||
Glow.pageTypeLabel = type => {
|
Glow.pageTypeLabel = type => {
|
||||||
const labels = {
|
const labels = {
|
||||||
project: "project",
|
project: "Project",
|
||||||
document: "document",
|
document: "Document",
|
||||||
update: "update",
|
update: "Update",
|
||||||
resource: "resource"
|
resource: "Resource"
|
||||||
};
|
};
|
||||||
return labels[type] || "page";
|
return labels[type] || "Page";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.apiListPageVersions = (teamSlug, pageSlug) =>
|
||||||
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/versions`);
|
||||||
|
Glow.apiGetPageVersion = versionId =>
|
||||||
|
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}`);
|
||||||
|
Glow.apiRestorePageVersion = versionId =>
|
||||||
|
Glow.apiRequest(`/api/page-versions/${encodeURIComponent(versionId)}/restore`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
Glow.apiListPageComments = (teamSlug, pageSlug) =>
|
||||||
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`);
|
||||||
|
Glow.apiCreatePageComment = (teamSlug, pageSlug, payload) =>
|
||||||
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
Glow.apiDeletePageComment = commentId =>
|
||||||
|
Glow.apiRequest(`/api/page-comments/${encodeURIComponent(commentId)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
Glow.apiCreatePageShare = (teamSlug, pageSlug) =>
|
||||||
|
Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/pages/${encodeURIComponent(pageSlug)}/share`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({})
|
||||||
|
});
|
||||||
|
|
||||||
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
|
Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
|
||||||
const team = Glow.state.activeTeam;
|
const team = Glow.state.activeTeam;
|
||||||
if (!team) {
|
if (!team) {
|
||||||
Glow.renderPagesEmptyState("select a team to view pages.");
|
Glow.renderPagesEmptyState("Select a team to view pages.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.els.pagesBox.setAttribute("aria-busy", "true");
|
Glow.els.pagesBox.setAttribute("aria-busy", "true");
|
||||||
try {
|
try {
|
||||||
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
|
const query = pageType ? `?type=${encodeURIComponent(pageType)}` : "";
|
||||||
@ -42,8 +69,8 @@ Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => {
|
|||||||
Glow.state.pageTypeFilter = pageType || "";
|
Glow.state.pageTypeFilter = pageType || "";
|
||||||
Glow.renderPages();
|
Glow.renderPages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.renderPagesEmptyState(error.message || "could not load pages.");
|
Glow.renderPagesEmptyState(error.message || "Could not load pages.");
|
||||||
Glow.showToast(error.message || "could not load pages.", "error");
|
Glow.showToast(error.message || "Could not load pages.", "error");
|
||||||
} finally {
|
} finally {
|
||||||
Glow.els.pagesBox.setAttribute("aria-busy", "false");
|
Glow.els.pagesBox.setAttribute("aria-busy", "false");
|
||||||
}
|
}
|
||||||
@ -57,12 +84,11 @@ Glow.renderPages = () => {
|
|||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "pages-header";
|
header.className = "pages-header";
|
||||||
|
|
||||||
const copy = document.createElement("div");
|
const copy = document.createElement("div");
|
||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
title.textContent = `${team?.name || "team"} pages`;
|
title.textContent = `${team?.name || "Team"} pages`;
|
||||||
const subtitle = document.createElement("p");
|
const subtitle = document.createElement("p");
|
||||||
subtitle.textContent = "shared team knowledge and public resources.";
|
subtitle.textContent = "Shared team knowledge and public resources.";
|
||||||
copy.append(title, subtitle);
|
copy.append(title, subtitle);
|
||||||
header.append(copy);
|
header.append(copy);
|
||||||
|
|
||||||
@ -70,23 +96,21 @@ Glow.renderPages = () => {
|
|||||||
const create = document.createElement("button");
|
const create = document.createElement("button");
|
||||||
create.className = "button";
|
create.className = "button";
|
||||||
create.type = "button";
|
create.type = "button";
|
||||||
create.textContent = "new page";
|
create.textContent = "New page";
|
||||||
create.addEventListener("click", Glow.createPage);
|
create.addEventListener("click", Glow.createPage);
|
||||||
header.append(create);
|
header.append(create);
|
||||||
}
|
}
|
||||||
|
|
||||||
box.append(header);
|
box.append(header);
|
||||||
|
|
||||||
const filters = document.createElement("div");
|
const filters = document.createElement("div");
|
||||||
filters.className = "page-filters compact";
|
filters.className = "page-filters";
|
||||||
const filterOptions = [
|
const filterOptions = [
|
||||||
["", "all"],
|
["", "All"],
|
||||||
["project", "projects"],
|
["project", "Projects"],
|
||||||
["document", "documents"],
|
["document", "Documents"],
|
||||||
["update", "updates"],
|
["update", "Updates"],
|
||||||
["resource", "resources"]
|
["resource", "Resources"]
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [value, label] of filterOptions) {
|
for (const [value, label] of filterOptions) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.className = "page-filter";
|
button.className = "page-filter";
|
||||||
@ -96,7 +120,6 @@ Glow.renderPages = () => {
|
|||||||
button.addEventListener("click", () => Glow.loadPages(value));
|
button.addEventListener("click", () => Glow.loadPages(value));
|
||||||
filters.append(button);
|
filters.append(button);
|
||||||
}
|
}
|
||||||
|
|
||||||
box.append(filters);
|
box.append(filters);
|
||||||
|
|
||||||
if (!pages.length) {
|
if (!pages.length) {
|
||||||
@ -104,7 +127,7 @@ Glow.renderPages = () => {
|
|||||||
empty.className = "empty-state page-empty-state";
|
empty.className = "empty-state page-empty-state";
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "empty-card";
|
card.className = "empty-card";
|
||||||
card.textContent = "no pages match this filter.";
|
card.textContent = "No pages match this filter.";
|
||||||
empty.append(card);
|
empty.append(card);
|
||||||
box.append(empty);
|
box.append(empty);
|
||||||
return;
|
return;
|
||||||
@ -112,17 +135,15 @@ Glow.renderPages = () => {
|
|||||||
|
|
||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
list.className = "pages-list";
|
list.className = "pages-list";
|
||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
list.append(Glow.buildPageCard(page));
|
list.append(Glow.buildPageCard(page));
|
||||||
}
|
}
|
||||||
|
|
||||||
box.append(list);
|
box.append(list);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.buildPageCard = page => {
|
Glow.buildPageCard = page => {
|
||||||
const article = document.createElement("article");
|
const article = document.createElement("article");
|
||||||
article.className = "page-card compact";
|
article.className = "page-card";
|
||||||
article.tabIndex = 0;
|
article.tabIndex = 0;
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
@ -141,19 +162,27 @@ Glow.buildPageCard = page => {
|
|||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.textContent = page.title;
|
title.textContent = page.title;
|
||||||
|
|
||||||
|
const excerpt = document.createElement("p");
|
||||||
|
excerpt.className = "page-card-excerpt";
|
||||||
|
const compactBody = (page.body || "").replace(/\s+/g, " ").trim();
|
||||||
|
excerpt.textContent =
|
||||||
|
compactBody.length > 180
|
||||||
|
? `${compactBody.slice(0, 180)}…`
|
||||||
|
: compactBody || "No content yet.";
|
||||||
|
|
||||||
const footer = document.createElement("div");
|
const footer = document.createElement("div");
|
||||||
footer.className = "page-card-footer";
|
footer.className = "page-card-footer";
|
||||||
|
|
||||||
const context = document.createElement("span");
|
const context = document.createElement("span");
|
||||||
context.textContent = page.channel_name
|
context.textContent = page.parent_page_title
|
||||||
? `#${page.channel_name}`
|
? `Child of ${page.parent_page_title}`
|
||||||
: "team page";
|
: page.channel_name
|
||||||
|
? `#${page.channel_name}`
|
||||||
|
: "Team page";
|
||||||
|
|
||||||
const updated = document.createElement("time");
|
const updated = document.createElement("time");
|
||||||
updated.dateTime = page.updated_at;
|
updated.dateTime = page.updated_at;
|
||||||
updated.title = new Date(page.updated_at).toLocaleString();
|
updated.title = new Date(page.updated_at).toLocaleString();
|
||||||
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
|
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||||
|
|
||||||
footer.append(context, updated);
|
footer.append(context, updated);
|
||||||
|
|
||||||
const open = () => Glow.openPage(page.slug);
|
const open = () => Glow.openPage(page.slug);
|
||||||
@ -165,7 +194,7 @@ Glow.buildPageCard = page => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
article.append(meta, title, footer);
|
article.append(meta, title, excerpt, footer);
|
||||||
return article;
|
return article;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -180,12 +209,10 @@ Glow.openPages = async () => {
|
|||||||
Glow.openPage = async pageSlug => {
|
Glow.openPage = async pageSlug => {
|
||||||
const team = Glow.state.activeTeam;
|
const team = Glow.state.activeTeam;
|
||||||
if (!team) return;
|
if (!team) return;
|
||||||
|
|
||||||
Glow.state.activeView = "page";
|
Glow.state.activeView = "page";
|
||||||
Glow.state.activePage = null;
|
Glow.state.activePage = null;
|
||||||
Glow.updateViewUI();
|
Glow.updateViewUI();
|
||||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
|
Glow.els.pageDetailBox.setAttribute("aria-busy", "true");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiRequest(
|
const data = await Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
|
`/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}`
|
||||||
@ -193,13 +220,244 @@ Glow.openPage = async pageSlug => {
|
|||||||
Glow.state.activePage = data.page;
|
Glow.state.activePage = data.page;
|
||||||
Glow.renderPageDetail();
|
Glow.renderPageDetail();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not load page.", "error");
|
Glow.showToast(error.message || "Could not load page.", "error");
|
||||||
Glow.openPages();
|
Glow.openPages();
|
||||||
} finally {
|
} finally {
|
||||||
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
|
Glow.els.pageDetailBox.setAttribute("aria-busy", "false");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Glow.renderPageComments = page => {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "profile-section";
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = "Comments";
|
||||||
|
section.append(heading);
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "page-comments-list";
|
||||||
|
|
||||||
|
const comments = Array.isArray(page.comments) ? page.comments : [];
|
||||||
|
if (!comments.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "profile-empty";
|
||||||
|
empty.textContent = "No comments yet.";
|
||||||
|
list.append(empty);
|
||||||
|
} else {
|
||||||
|
for (const comment of comments) {
|
||||||
|
const item = document.createElement("article");
|
||||||
|
item.className = "page-comment";
|
||||||
|
|
||||||
|
const top = document.createElement("div");
|
||||||
|
top.className = "page-comment-meta";
|
||||||
|
const author = document.createElement("strong");
|
||||||
|
author.textContent = comment.username;
|
||||||
|
author.style.color = comment.user_name_color || "";
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.dateTime = comment.created_at;
|
||||||
|
time.textContent = new Date(comment.created_at).toLocaleString();
|
||||||
|
top.append(author, time);
|
||||||
|
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "page-comment-body";
|
||||||
|
body.textContent = comment.body;
|
||||||
|
|
||||||
|
item.append(top, body);
|
||||||
|
|
||||||
|
const canDelete =
|
||||||
|
Glow.state.currentUser &&
|
||||||
|
(Number(Glow.state.currentUser.id) === Number(comment.user_id) ||
|
||||||
|
Glow.state.currentUser.role === "admin" ||
|
||||||
|
page.can_edit);
|
||||||
|
if (canDelete) {
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "small-button danger";
|
||||||
|
remove.type = "button";
|
||||||
|
remove.textContent = "Delete";
|
||||||
|
remove.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await Glow.apiDeletePageComment(comment.id);
|
||||||
|
await Glow.openPage(page.slug);
|
||||||
|
Glow.showToast("Comment deleted.");
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not delete comment.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
item.append(remove);
|
||||||
|
}
|
||||||
|
|
||||||
|
list.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
section.append(list);
|
||||||
|
|
||||||
|
if (Glow.state.currentUser) {
|
||||||
|
const form = document.createElement("form");
|
||||||
|
form.className = "page-comment-form";
|
||||||
|
|
||||||
|
const area = document.createElement("textarea");
|
||||||
|
area.className = "field";
|
||||||
|
area.rows = 4;
|
||||||
|
area.maxLength = 3000;
|
||||||
|
area.placeholder = "Add a comment";
|
||||||
|
|
||||||
|
const submit = document.createElement("button");
|
||||||
|
submit.className = "button";
|
||||||
|
submit.type = "submit";
|
||||||
|
submit.textContent = "Post comment";
|
||||||
|
|
||||||
|
form.append(area, submit);
|
||||||
|
form.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const body = area.value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
try {
|
||||||
|
await Glow.apiCreatePageComment(page.team_slug, page.slug, { body });
|
||||||
|
await Glow.openPage(page.slug);
|
||||||
|
Glow.showToast("Comment added.");
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not add comment.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
section.append(form);
|
||||||
|
}
|
||||||
|
|
||||||
|
return section;
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.renderPageBacklinks = page => {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "profile-section";
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = "Backlinks";
|
||||||
|
section.append(heading);
|
||||||
|
|
||||||
|
const backlinks = Array.isArray(page.backlinks) ? page.backlinks : [];
|
||||||
|
if (!backlinks.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "profile-empty";
|
||||||
|
empty.textContent = "No backlinks yet.";
|
||||||
|
section.append(empty);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "page-related-list";
|
||||||
|
for (const link of backlinks) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "small-button";
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = link.title;
|
||||||
|
button.addEventListener("click", () => Glow.openPage(link.slug));
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
section.append(list);
|
||||||
|
return section;
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.renderPageChildren = page => {
|
||||||
|
const section = document.createElement("section");
|
||||||
|
section.className = "profile-section";
|
||||||
|
const heading = document.createElement("h3");
|
||||||
|
heading.textContent = "Child pages";
|
||||||
|
section.append(heading);
|
||||||
|
|
||||||
|
const children = Array.isArray(page.children) ? page.children : [];
|
||||||
|
if (!children.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "profile-empty";
|
||||||
|
empty.textContent = "No child pages.";
|
||||||
|
section.append(empty);
|
||||||
|
return section;
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = document.createElement("div");
|
||||||
|
list.className = "page-related-list";
|
||||||
|
for (const child of children) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = "small-button";
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = child.title;
|
||||||
|
button.addEventListener("click", () => Glow.openPage(child.slug));
|
||||||
|
list.append(button);
|
||||||
|
}
|
||||||
|
section.append(list);
|
||||||
|
return section;
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.openPageVersionHistory = async page => {
|
||||||
|
try {
|
||||||
|
const data = await Glow.apiListPageVersions(page.team_slug, page.slug);
|
||||||
|
const container = document.createElement("div");
|
||||||
|
container.className = "member-list";
|
||||||
|
|
||||||
|
const versions = data.versions || [];
|
||||||
|
if (!versions.length) {
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "member-row";
|
||||||
|
empty.textContent = "No version history yet.";
|
||||||
|
container.append(empty);
|
||||||
|
} else {
|
||||||
|
for (const version of versions) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "member-row";
|
||||||
|
const left = document.createElement("button");
|
||||||
|
left.className = "small-button";
|
||||||
|
left.type = "button";
|
||||||
|
left.textContent = `v${version.version_number} · ${version.title}`;
|
||||||
|
left.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const detail = await Glow.apiGetPageVersion(version.id);
|
||||||
|
const body = document.createElement("div");
|
||||||
|
body.className = "page-body markdown-body";
|
||||||
|
body.innerHTML = detail.version.body_html || "<p>No content.</p>";
|
||||||
|
const result = await Glow.openDialog({
|
||||||
|
title: `Version ${detail.version.version_number}`,
|
||||||
|
body,
|
||||||
|
confirmText: page.can_edit ? "Restore version" : "Close",
|
||||||
|
cancelText: "Close",
|
||||||
|
danger: page.can_edit
|
||||||
|
});
|
||||||
|
if (result && page.can_edit) {
|
||||||
|
await Glow.apiRestorePageVersion(version.id);
|
||||||
|
Glow.showToast("Version restored.");
|
||||||
|
await Glow.openPage(page.slug);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not load version.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const right = document.createElement("span");
|
||||||
|
right.textContent = new Date(version.created_at).toLocaleString();
|
||||||
|
row.append(left, right);
|
||||||
|
container.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Glow.openDialog({
|
||||||
|
title: "Version history",
|
||||||
|
body: container,
|
||||||
|
confirmText: "Close",
|
||||||
|
cancelText: "Close"
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not load version history.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.shareCurrentPage = async page => {
|
||||||
|
try {
|
||||||
|
const data = await Glow.apiCreatePageShare(page.team_slug, page.slug);
|
||||||
|
const url = new URL(data.share.url, window.location.origin).toString();
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
Glow.showToast("Page share link copied.");
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not create page share.", "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Glow.renderPageDetail = () => {
|
Glow.renderPageDetail = () => {
|
||||||
const box = Glow.els.pageDetailBox;
|
const box = Glow.els.pageDetailBox;
|
||||||
const page = Glow.state.activePage;
|
const page = Glow.state.activePage;
|
||||||
@ -210,7 +468,7 @@ Glow.renderPageDetail = () => {
|
|||||||
state.className = "empty-state";
|
state.className = "empty-state";
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "empty-card";
|
card.className = "empty-card";
|
||||||
card.textContent = "page not found.";
|
card.textContent = "Page not found.";
|
||||||
state.append(card);
|
state.append(card);
|
||||||
box.append(state);
|
box.append(state);
|
||||||
return;
|
return;
|
||||||
@ -234,7 +492,7 @@ Glow.renderPageDetail = () => {
|
|||||||
updated.className = "page-updated";
|
updated.className = "page-updated";
|
||||||
updated.dateTime = page.updated_at;
|
updated.dateTime = page.updated_at;
|
||||||
updated.title = new Date(page.updated_at).toLocaleString();
|
updated.title = new Date(page.updated_at).toLocaleString();
|
||||||
updated.textContent = `updated ${Glow.relativeTime(page.updated_at)}`;
|
updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`;
|
||||||
|
|
||||||
meta.append(type, visibility, updated);
|
meta.append(type, visibility, updated);
|
||||||
|
|
||||||
@ -243,32 +501,57 @@ Glow.renderPageDetail = () => {
|
|||||||
|
|
||||||
const context = document.createElement("p");
|
const context = document.createElement("p");
|
||||||
context.className = "page-detail-context";
|
context.className = "page-detail-context";
|
||||||
context.textContent = page.channel_name
|
context.textContent = page.parent_page_title
|
||||||
? `${page.team_name} · #${page.channel_name}`
|
? `${page.team_name} · Child of ${page.parent_page_title}`
|
||||||
: page.team_name;
|
: page.channel_name
|
||||||
|
? `${page.team_name} · #${page.channel_name}`
|
||||||
|
: page.team_name;
|
||||||
|
|
||||||
header.append(meta, title, context);
|
header.append(meta, title, context);
|
||||||
box.append(header);
|
box.append(header);
|
||||||
|
|
||||||
const body = document.createElement("article");
|
const body = document.createElement("article");
|
||||||
body.className = "page-body markdown-body";
|
body.className = "page-body markdown-body";
|
||||||
body.innerHTML = page.body_html || "<p>no content yet.</p>";
|
body.innerHTML = page.body_html || "<p>No content yet.</p>";
|
||||||
box.append(body);
|
box.append(body);
|
||||||
|
|
||||||
|
const related = document.createElement("div");
|
||||||
|
related.className = "profile-grid";
|
||||||
|
related.append(
|
||||||
|
Glow.renderPageChildren(page),
|
||||||
|
Glow.renderPageBacklinks(page),
|
||||||
|
Glow.renderPageComments(page)
|
||||||
|
);
|
||||||
|
box.append(related);
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "page-detail-actions";
|
actions.className = "page-detail-actions";
|
||||||
|
|
||||||
|
const history = document.createElement("button");
|
||||||
|
history.className = "button secondary";
|
||||||
|
history.type = "button";
|
||||||
|
history.textContent = "Version history";
|
||||||
|
history.addEventListener("click", () => Glow.openPageVersionHistory(page));
|
||||||
|
actions.append(history);
|
||||||
|
|
||||||
|
const share = document.createElement("button");
|
||||||
|
share.className = "button secondary";
|
||||||
|
share.type = "button";
|
||||||
|
share.textContent = "Share";
|
||||||
|
share.addEventListener("click", () => Glow.shareCurrentPage(page));
|
||||||
|
actions.append(share);
|
||||||
|
|
||||||
if (page.can_edit) {
|
if (page.can_edit) {
|
||||||
const edit = document.createElement("button");
|
const edit = document.createElement("button");
|
||||||
edit.className = "button";
|
edit.className = "button";
|
||||||
edit.type = "button";
|
edit.type = "button";
|
||||||
edit.textContent = "edit";
|
edit.textContent = "Edit";
|
||||||
edit.addEventListener("click", () => Glow.editPage(page));
|
edit.addEventListener("click", () => Glow.editPage(page));
|
||||||
|
|
||||||
const remove = document.createElement("button");
|
const remove = document.createElement("button");
|
||||||
remove.className = "danger-button";
|
remove.className = "danger-button";
|
||||||
remove.type = "button";
|
remove.type = "button";
|
||||||
remove.textContent = "delete";
|
remove.textContent = "Delete";
|
||||||
remove.addEventListener("click", () => Glow.deletePage(page));
|
remove.addEventListener("click", () => Glow.deletePage(page));
|
||||||
|
|
||||||
actions.append(edit, remove);
|
actions.append(edit, remove);
|
||||||
@ -280,33 +563,40 @@ Glow.renderPageDetail = () => {
|
|||||||
Glow.pageDialogFields = (page = null) => [
|
Glow.pageDialogFields = (page = null) => [
|
||||||
{
|
{
|
||||||
name: "title",
|
name: "title",
|
||||||
label: "title",
|
label: "Title",
|
||||||
value: page?.title || "",
|
value: page?.title || "",
|
||||||
placeholder: "page title",
|
placeholder: "Page title",
|
||||||
maxLength: 160
|
maxLength: 160
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "page_type",
|
name: "page_type",
|
||||||
label: "type: project, document, update, or resource",
|
label: "Type: project, document, update, or resource",
|
||||||
value: page?.page_type || "document",
|
value: page?.page_type || "document",
|
||||||
maxLength: 12
|
maxLength: 12
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "visibility",
|
name: "visibility",
|
||||||
label: "visibility: team or public",
|
label: "Visibility: team or public",
|
||||||
value: page?.visibility || "team",
|
value: page?.visibility || "team",
|
||||||
maxLength: 6
|
maxLength: 6
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "channel_slug",
|
name: "channel_slug",
|
||||||
label: "channel slug (optional)",
|
label: "Channel slug (optional)",
|
||||||
value: page?.channel_slug || "",
|
value: page?.channel_slug || "",
|
||||||
placeholder: "general",
|
placeholder: "general",
|
||||||
maxLength: 48
|
maxLength: 48
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "parent_page_slug",
|
||||||
|
label: "Parent page slug (optional)",
|
||||||
|
value: page?.parent_page_slug || "",
|
||||||
|
placeholder: "project-overview",
|
||||||
|
maxLength: 48
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "body",
|
name: "body",
|
||||||
label: "markdown content",
|
label: "Markdown content",
|
||||||
value: page?.body || "",
|
value: page?.body || "",
|
||||||
multiline: true,
|
multiline: true,
|
||||||
rows: 14,
|
rows: 14,
|
||||||
@ -316,46 +606,41 @@ Glow.pageDialogFields = (page = null) => [
|
|||||||
|
|
||||||
Glow.validatePageValues = values => {
|
Glow.validatePageValues = values => {
|
||||||
if (!values.title) {
|
if (!values.title) {
|
||||||
Glow.showToast("page title is required.", "error");
|
Glow.showToast("Page title is required.", "error");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pageType = values.page_type.toLowerCase();
|
const pageType = values.page_type.toLowerCase();
|
||||||
if (!["project", "document", "update", "resource"].includes(pageType)) {
|
if (!["project", "document", "update", "resource"].includes(pageType)) {
|
||||||
Glow.showToast("choose a valid page type.", "error");
|
Glow.showToast("Choose a valid page type.", "error");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibility = values.visibility.toLowerCase();
|
const visibility = values.visibility.toLowerCase();
|
||||||
if (!["team", "public"].includes(visibility)) {
|
if (!["team", "public"].includes(visibility)) {
|
||||||
Glow.showToast("visibility must be team or public.", "error");
|
Glow.showToast("Visibility must be team or public.", "error");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: values.title,
|
title: values.title,
|
||||||
body: values.body,
|
body: values.body,
|
||||||
page_type: pageType,
|
page_type: pageType,
|
||||||
visibility,
|
visibility,
|
||||||
channel_slug: values.channel_slug.toLowerCase()
|
channel_slug: values.channel_slug.toLowerCase(),
|
||||||
|
parent_page_slug: values.parent_page_slug.toLowerCase()
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.createPage = async () => {
|
Glow.createPage = async () => {
|
||||||
const team = Glow.state.activeTeam;
|
const team = Glow.state.activeTeam;
|
||||||
if (!team?.can_manage) return;
|
if (!team?.can_manage) return;
|
||||||
|
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: "create page",
|
title: "Create page",
|
||||||
body: "create a shared team page or a public resource. markdown is supported.",
|
body: "Create a shared team page or a public resource. Markdown is supported.",
|
||||||
confirmText: "create page",
|
confirmText: "Create page",
|
||||||
fields: Glow.pageDialogFields()
|
fields: Glow.pageDialogFields()
|
||||||
});
|
});
|
||||||
if (!values) return;
|
if (!values) return;
|
||||||
|
|
||||||
const payload = Glow.validatePageValues(values);
|
const payload = Glow.validatePageValues(values);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiRequest(
|
const data = await Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
|
`/api/teams/${encodeURIComponent(team.slug)}/pages`,
|
||||||
@ -365,26 +650,23 @@ Glow.createPage = async () => {
|
|||||||
body: JSON.stringify(payload)
|
body: JSON.stringify(payload)
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
Glow.showToast("page created.");
|
Glow.showToast("Page created.");
|
||||||
await Glow.openPage(data.page.slug);
|
await Glow.openPage(data.page.slug);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not create page.", "error");
|
Glow.showToast(error.message || "Could not create page.", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.editPage = async page => {
|
Glow.editPage = async page => {
|
||||||
if (!page.can_edit) return;
|
if (!page.can_edit) return;
|
||||||
|
|
||||||
const values = await Glow.openDialog({
|
const values = await Glow.openDialog({
|
||||||
title: `edit ${page.title}`,
|
title: `Edit ${page.title}`,
|
||||||
confirmText: "save",
|
confirmText: "Save",
|
||||||
fields: Glow.pageDialogFields(page)
|
fields: Glow.pageDialogFields(page)
|
||||||
});
|
});
|
||||||
if (!values) return;
|
if (!values) return;
|
||||||
|
|
||||||
const payload = Glow.validatePageValues(values);
|
const payload = Glow.validatePageValues(values);
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiRequest(
|
const data = await Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
||||||
@ -396,31 +678,29 @@ Glow.editPage = async page => {
|
|||||||
);
|
);
|
||||||
Glow.state.activePage = data.page;
|
Glow.state.activePage = data.page;
|
||||||
Glow.renderPageDetail();
|
Glow.renderPageDetail();
|
||||||
Glow.showToast("page updated.");
|
Glow.showToast("Page updated.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not update page.", "error");
|
Glow.showToast(error.message || "Could not update page.", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.deletePage = async page => {
|
Glow.deletePage = async page => {
|
||||||
if (!page.can_edit) return;
|
if (!page.can_edit) return;
|
||||||
|
|
||||||
const result = await Glow.openDialog({
|
const result = await Glow.openDialog({
|
||||||
title: `delete ${page.title}?`,
|
title: `Delete ${page.title}?`,
|
||||||
body: "this page will no longer be available.",
|
body: "This page will no longer be available.",
|
||||||
confirmText: "delete page",
|
confirmText: "Delete page",
|
||||||
danger: true
|
danger: true
|
||||||
});
|
});
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Glow.apiRequest(
|
await Glow.apiRequest(
|
||||||
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
`/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`,
|
||||||
{ method: "DELETE" }
|
{ method: "DELETE" }
|
||||||
);
|
);
|
||||||
Glow.showToast("page deleted.");
|
Glow.showToast("Page deleted.");
|
||||||
await Glow.openPages();
|
await Glow.openPages();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "could not delete page.", "error");
|
Glow.showToast(error.message || "Could not delete page.", "error");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -3,14 +3,11 @@
|
|||||||
|
|
||||||
window.Glow = window.Glow || {};
|
window.Glow = window.Glow || {};
|
||||||
|
|
||||||
// note:
|
|
||||||
// this file keeps team/channel helper methods available,
|
|
||||||
// but the old production monkey-patching adapter is disabled.
|
|
||||||
// rooms remain the stable fallback runtime path.
|
|
||||||
|
|
||||||
Glow.teamAdapter = Glow.teamAdapter || {
|
Glow.teamAdapter = Glow.teamAdapter || {
|
||||||
initialized: false,
|
initialized: false,
|
||||||
enabled: false
|
originalLoadRooms: null,
|
||||||
|
originalUpdateViewUI: null,
|
||||||
|
chatBarVisibilityInstalled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.loadTeamsForAdapter = async () => {
|
Glow.loadTeamsForAdapter = async () => {
|
||||||
@ -46,7 +43,7 @@ Glow.loadTeamsForAdapter = async () => {
|
|||||||
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
|
Glow.els.teamSelect.value = Glow.state.activeTeam.slug;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("could not load teams:", error);
|
console.warn("Team adapter could not load teams:", error);
|
||||||
Glow.state.teams = [];
|
Glow.state.teams = [];
|
||||||
Glow.state.activeTeam = null;
|
Glow.state.activeTeam = null;
|
||||||
Glow.els.teamSelect.disabled = true;
|
Glow.els.teamSelect.disabled = true;
|
||||||
@ -72,6 +69,7 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
|
|||||||
Glow.state.channels = channels;
|
Glow.state.channels = channels;
|
||||||
|
|
||||||
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
|
const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug);
|
||||||
|
|
||||||
if (matchingTeam || data.team) {
|
if (matchingTeam || data.team) {
|
||||||
Glow.state.activeTeam = {
|
Glow.state.activeTeam = {
|
||||||
...(matchingTeam || {}),
|
...(matchingTeam || {}),
|
||||||
@ -91,21 +89,24 @@ Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => {
|
|||||||
const selected =
|
const selected =
|
||||||
channels.find(channel => channel.slug === preferredChannelSlug) ||
|
channels.find(channel => channel.slug === preferredChannelSlug) ||
|
||||||
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
|
channels.find(channel => channel.slug === Glow.state.activeChannel?.slug) ||
|
||||||
|
channels.find(channel => channel.slug === Glow.state.activeRoom?.slug) ||
|
||||||
channels.find(channel => channel.slug === "general") ||
|
channels.find(channel => channel.slug === "general") ||
|
||||||
channels[0] ||
|
channels[0] ||
|
||||||
null;
|
null;
|
||||||
|
|
||||||
Glow.state.activeChannel = selected;
|
Glow.state.activeChannel = selected;
|
||||||
if (selected) {
|
Glow.state.activeRoom = selected;
|
||||||
Glow.state.activeRoom = selected;
|
|
||||||
Glow.els.roomSelect.value = selected.slug;
|
|
||||||
}
|
|
||||||
|
|
||||||
Glow.els.roomSelect.disabled = !selected;
|
Glow.els.roomSelect.disabled = !selected;
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
Glow.els.roomSelect.value = selected.slug;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.decorateChannelSelector = () => {
|
Glow.decorateChannelSelector = () => {
|
||||||
if (!Glow.els.roomSelect) return;
|
if (!Glow.els.roomSelect) return;
|
||||||
|
|
||||||
Glow.els.roomSelect.classList.add("channel-select");
|
Glow.els.roomSelect.classList.add("channel-select");
|
||||||
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
|
Glow.els.roomSelect.setAttribute("aria-label", "Select channel");
|
||||||
};
|
};
|
||||||
@ -113,23 +114,21 @@ Glow.decorateChannelSelector = () => {
|
|||||||
Glow.updateTeamChannelHeader = () => {
|
Glow.updateTeamChannelHeader = () => {
|
||||||
const team = Glow.state.activeTeam;
|
const team = Glow.state.activeTeam;
|
||||||
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
const channel = Glow.state.activeChannel || Glow.state.activeRoom;
|
||||||
const view = Glow.state.activeView;
|
|
||||||
|
const isChannelView =
|
||||||
|
Glow.state.activeView === "channel" ||
|
||||||
|
Glow.state.activeView === "room";
|
||||||
|
|
||||||
if (
|
if (
|
||||||
view === "dm" ||
|
Glow.state.activeView === "dm" ||
|
||||||
view === "dms" ||
|
Glow.state.activeView === "dms"
|
||||||
view === "feed" ||
|
|
||||||
view === "pages" ||
|
|
||||||
view === "page" ||
|
|
||||||
view === "profile" ||
|
|
||||||
view === "settings" ||
|
|
||||||
view === "thread"
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!(view === "channel" || view === "room")) return;
|
if (!isChannelView || !team || !channel || !Glow.els.headerTitle) {
|
||||||
if (!team || !channel || !Glow.els.headerTitle) return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Glow.els.headerTitle.textContent = `# ${channel.name}`;
|
Glow.els.headerTitle.textContent = `# ${channel.name}`;
|
||||||
Glow.els.headerSubtitle.textContent =
|
Glow.els.headerSubtitle.textContent =
|
||||||
@ -531,10 +530,152 @@ Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) =>
|
|||||||
|
|
||||||
Glow.installTeamAdapter = () => {
|
Glow.installTeamAdapter = () => {
|
||||||
if (Glow.teamAdapter.initialized) return;
|
if (Glow.teamAdapter.initialized) return;
|
||||||
|
|
||||||
|
if (!Glow.els.teamSelect || !Glow.els.roomSelect) {
|
||||||
|
console.warn("Team adapter skipped: expected team/channel elements are missing.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Glow.teamAdapter.initialized = true;
|
Glow.teamAdapter.initialized = true;
|
||||||
|
|
||||||
|
Glow.state.teams ||= [];
|
||||||
|
Glow.state.channels ||= [];
|
||||||
|
Glow.state.activeTeam ||= null;
|
||||||
|
Glow.state.activeChannel ||= null;
|
||||||
|
|
||||||
Glow.decorateChannelSelector();
|
Glow.decorateChannelSelector();
|
||||||
console.info("team/channel helper mode enabled; runtime monkey-patching disabled.");
|
|
||||||
|
Glow.teamAdapter.originalLoadRooms = Glow.loadRooms;
|
||||||
|
|
||||||
|
Glow.loadRooms = async () => {
|
||||||
|
await Glow.teamAdapter.originalLoadRooms();
|
||||||
|
await Glow.loadTeamsForAdapter();
|
||||||
|
Glow.decorateChannelSelector();
|
||||||
|
|
||||||
|
if (Glow.state.activeTeam) {
|
||||||
|
try {
|
||||||
|
await Glow.loadChannelsForTeam(
|
||||||
|
Glow.state.activeTeam.slug,
|
||||||
|
Glow.state.activeRoom?.slug
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Team adapter could not load channels after rooms:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Glow.updateTeamChannelHeader();
|
||||||
|
};
|
||||||
|
|
||||||
|
Glow.teamAdapter.originalUpdateViewUI = Glow.updateViewUI;
|
||||||
|
|
||||||
|
Glow.updateViewUI = () => {
|
||||||
|
Glow.teamAdapter.originalUpdateViewUI?.();
|
||||||
|
Glow.updateTeamChannelHeader();
|
||||||
|
Glow.applyComposerVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
const initializeAdapter = async () => {
|
||||||
|
await Glow.loadTeamsForAdapter();
|
||||||
|
Glow.decorateChannelSelector();
|
||||||
|
|
||||||
|
if (!Glow.state.activeTeam) {
|
||||||
|
Glow.applyComposerVisibility();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Glow.loadChannelsForTeam(
|
||||||
|
Glow.state.activeTeam.slug,
|
||||||
|
Glow.state.activeRoom?.slug
|
||||||
|
);
|
||||||
|
|
||||||
|
Glow.updateTeamChannelHeader();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Team adapter could not load initial channels:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
Glow.applyComposerVisibility();
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeAdapter();
|
||||||
|
|
||||||
|
Glow.els.teamSelect.addEventListener("change", async () => {
|
||||||
|
const selected = Glow.state.teams.find(
|
||||||
|
team => team.slug === Glow.els.teamSelect.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
Glow.state.activeDm = null;
|
||||||
|
Glow.state.activeTeam = selected;
|
||||||
|
Glow.state.activeChannel = null;
|
||||||
|
Glow.state.activeRoom = null;
|
||||||
|
|
||||||
|
Glow.state.typingUsers = [];
|
||||||
|
Glow.state.dmTypingUsers = [];
|
||||||
|
Glow.renderTyping?.();
|
||||||
|
Glow.clearMessages?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Glow.loadChannelsForTeam(selected.slug);
|
||||||
|
|
||||||
|
Glow.state.activeView = "channel";
|
||||||
|
Glow.updateViewUI();
|
||||||
|
|
||||||
|
if (Glow.state.activeChannel) {
|
||||||
|
await Glow.loadChannelMessages?.();
|
||||||
|
Glow.pollTyping?.();
|
||||||
|
Glow.pingPresence?.();
|
||||||
|
Glow.refreshPresence?.();
|
||||||
|
} else {
|
||||||
|
Glow.renderEmptyState?.("No channels are available in this team.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not load channels for this team.", "error");
|
||||||
|
}
|
||||||
|
|
||||||
|
Glow.schedulePoll?.();
|
||||||
|
});
|
||||||
|
|
||||||
|
Glow.els.roomSelect.addEventListener("change", () => {
|
||||||
|
window.setTimeout(() => {
|
||||||
|
const selected = Glow.state.channels.find(
|
||||||
|
channel => channel.slug === Glow.els.roomSelect.value
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
Glow.state.activeDm = null;
|
||||||
|
Glow.state.activeChannel = selected;
|
||||||
|
Glow.state.activeRoom = selected;
|
||||||
|
|
||||||
|
if (
|
||||||
|
Glow.state.activeView === "room" ||
|
||||||
|
Glow.state.activeView === "channel"
|
||||||
|
) {
|
||||||
|
Glow.state.activeView = "channel";
|
||||||
|
}
|
||||||
|
|
||||||
|
Glow.updateViewUI();
|
||||||
|
Glow.pingPresence?.();
|
||||||
|
Glow.refreshPresence?.();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.info("Team/channel adapter enabled.");
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.installTeamAdapter();
|
Glow.installTeamAdapter();
|
||||||
|
|
||||||
|
if (!Glow.teamAdapter.chatBarVisibilityInstalled) {
|
||||||
|
Glow.teamAdapter.chatBarVisibilityInstalled = true;
|
||||||
|
|
||||||
|
const previousUpdateViewUI = Glow.updateViewUI;
|
||||||
|
|
||||||
|
Glow.updateViewUI = () => {
|
||||||
|
previousUpdateViewUI?.();
|
||||||
|
Glow.applyComposerVisibility();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Glow.applyComposerVisibility();
|
Glow.applyComposerVisibility();
|
||||||
@ -20,161 +20,21 @@ Glow.applyCustomCss = cssText => {
|
|||||||
style.textContent = typeof cssText === "string" ? cssText : "";
|
style.textContent = typeof cssText === "string" ? cssText : "";
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.cloneSettingsState = source => {
|
|
||||||
const value = source && typeof source === "object" ? source : {};
|
|
||||||
return {
|
|
||||||
accent_color:
|
|
||||||
Glow.normalizeAccentColor(value.accent_color || Glow.state.accentColor) ||
|
|
||||||
Glow.DEFAULT_ACCENT_COLOR,
|
|
||||||
name_color:
|
|
||||||
Glow.normalizeNameColor(value.name_color) || null,
|
|
||||||
custom_css:
|
|
||||||
typeof value.custom_css === "string" ? value.custom_css : "",
|
|
||||||
theme_mode:
|
|
||||||
value.theme_mode === "light" ? "light" : "dark",
|
|
||||||
profile_background_color:
|
|
||||||
Glow.normalizeAccentColor(value.profile_background_color) || "#121214",
|
|
||||||
profile_banner_color:
|
|
||||||
Glow.normalizeAccentColor(value.profile_banner_color) || "#3a3140",
|
|
||||||
profile_visibility:
|
|
||||||
["public", "members", "private"].includes(
|
|
||||||
String(value.profile_visibility || "").toLowerCase()
|
|
||||||
)
|
|
||||||
? String(value.profile_visibility).toLowerCase()
|
|
||||||
: "public",
|
|
||||||
last_active_visibility:
|
|
||||||
["everyone", "members", "nobody"].includes(
|
|
||||||
String(value.last_active_visibility || "").toLowerCase()
|
|
||||||
)
|
|
||||||
? String(value.last_active_visibility).toLowerCase()
|
|
||||||
: "everyone",
|
|
||||||
presence_mode:
|
|
||||||
["online", "silent", "focus", "dnd"].includes(
|
|
||||||
String(value.presence_mode || "").toLowerCase()
|
|
||||||
)
|
|
||||||
? String(value.presence_mode).toLowerCase()
|
|
||||||
: "online"
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.initializeSettingsDraft = settings => {
|
|
||||||
const saved = Glow.cloneSettingsState(settings);
|
|
||||||
Glow.state.settings = saved;
|
|
||||||
Glow.state.settingsSaved = saved;
|
|
||||||
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
|
|
||||||
Glow.clearSettingsDirty?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.previewSettingsDraft = () => {
|
|
||||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
|
||||||
Glow.applyAccentColor(draft.accent_color);
|
|
||||||
Glow.applyThemeMode(draft.theme_mode);
|
|
||||||
Glow.applyCustomCss(draft.custom_css || "");
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.updateSettingsDraft = patch => {
|
|
||||||
const current = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
|
||||||
Glow.state.settingsDraft = {
|
|
||||||
...current,
|
|
||||||
...patch
|
|
||||||
};
|
|
||||||
Glow.previewSettingsDraft();
|
|
||||||
Glow.markSettingsDirty?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.syncSettingsSavePanel = () => {
|
|
||||||
const panel = Glow.els.settingsSavePanel;
|
|
||||||
if (!panel) return;
|
|
||||||
|
|
||||||
const shouldShow =
|
|
||||||
Glow.state.activeView === "settings" &&
|
|
||||||
Glow.isSettingsDirty?.();
|
|
||||||
|
|
||||||
panel.classList.toggle("hidden", !shouldShow);
|
|
||||||
|
|
||||||
if (Glow.els.settingsUnsavedIndicator) {
|
|
||||||
Glow.els.settingsUnsavedIndicator.textContent = shouldShow
|
|
||||||
? "Unsaved changes"
|
|
||||||
: "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.saveSettingsDraft = async () => {
|
|
||||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
accent_color:
|
|
||||||
Glow.normalizeAccentColor(draft.accent_color) ||
|
|
||||||
Glow.DEFAULT_ACCENT_COLOR,
|
|
||||||
name_color:
|
|
||||||
Glow.normalizeNameColor(draft.name_color) || null,
|
|
||||||
custom_css: draft.custom_css || "",
|
|
||||||
theme_mode: draft.theme_mode === "light" ? "light" : "dark",
|
|
||||||
profile_background_color:
|
|
||||||
Glow.normalizeAccentColor(draft.profile_background_color) || null,
|
|
||||||
profile_banner_color:
|
|
||||||
Glow.normalizeAccentColor(draft.profile_banner_color) || null,
|
|
||||||
profile_visibility:
|
|
||||||
["public", "members", "private"].includes(draft.profile_visibility)
|
|
||||||
? draft.profile_visibility
|
|
||||||
: "public",
|
|
||||||
last_active_visibility:
|
|
||||||
["everyone", "members", "nobody"].includes(draft.last_active_visibility)
|
|
||||||
? draft.last_active_visibility
|
|
||||||
: "everyone",
|
|
||||||
presence_mode:
|
|
||||||
["online", "silent", "focus", "dnd"].includes(draft.presence_mode)
|
|
||||||
? draft.presence_mode
|
|
||||||
: "online"
|
|
||||||
};
|
|
||||||
|
|
||||||
const data = await Glow.apiUpdateMySettings(payload);
|
|
||||||
const merged = {
|
|
||||||
...Glow.cloneSettingsState(draft),
|
|
||||||
...(data.settings || {})
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.state.settings = merged;
|
|
||||||
Glow.state.settingsSaved = Glow.cloneSettingsState(merged);
|
|
||||||
Glow.state.settingsDraft = Glow.cloneSettingsState(merged);
|
|
||||||
Glow.previewSettingsDraft();
|
|
||||||
Glow.clearSettingsDirty?.();
|
|
||||||
|
|
||||||
if (Glow.state.currentUser) {
|
|
||||||
Glow.state.currentUser.presence_mode =
|
|
||||||
merged.presence_mode || Glow.state.currentUser.presence_mode || "online";
|
|
||||||
}
|
|
||||||
|
|
||||||
Glow.showToast("Settings saved.");
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.discardSettingsDraft = () => {
|
|
||||||
const saved = Glow.state.settingsSaved || Glow.cloneSettingsState({});
|
|
||||||
Glow.state.settingsDraft = Glow.cloneSettingsState(saved);
|
|
||||||
Glow.previewSettingsDraft();
|
|
||||||
Glow.clearSettingsDirty?.();
|
|
||||||
if (Glow.state.activeView === "settings") {
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.loadMySettings = async () => {
|
Glow.loadMySettings = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiGetMySettings();
|
const data = await Glow.apiGetMySettings();
|
||||||
const base = Glow.cloneSettingsState(data.settings || {});
|
Glow.state.settings = data.settings || {};
|
||||||
Glow.initializeSettingsDraft(base);
|
const accent = Glow.state.settings.accent_color || Glow.state.accentColor;
|
||||||
Glow.previewSettingsDraft();
|
Glow.applyAccentColor(accent);
|
||||||
|
const mode =
|
||||||
|
Glow.state.settings.theme_mode ||
|
||||||
|
localStorage.getItem("glow_theme_mode") ||
|
||||||
|
"dark";
|
||||||
|
Glow.applyThemeMode(mode);
|
||||||
|
Glow.applyCustomCss(Glow.state.settings.custom_css || "");
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
const fallback = Glow.cloneSettingsState({
|
const mode = localStorage.getItem("glow_theme_mode") || "dark";
|
||||||
theme_mode: localStorage.getItem("glow_theme_mode") || "dark",
|
Glow.applyThemeMode(mode);
|
||||||
accent_color: Glow.state.accentColor,
|
|
||||||
profile_visibility: "public",
|
|
||||||
last_active_visibility: "everyone",
|
|
||||||
presence_mode: "online"
|
|
||||||
});
|
|
||||||
Glow.initializeSettingsDraft(fallback);
|
|
||||||
Glow.previewSettingsDraft();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -182,19 +42,15 @@ Glow.renderHeaderMenu = () => {
|
|||||||
const list = Glow.els.headerMenuList;
|
const list = Glow.els.headerMenuList;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
|
|
||||||
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
|
const addItem = ({ label, hint = "", onClick, hidden = false }) => {
|
||||||
if (hidden) return;
|
if (hidden) return;
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.className = "header-menu-item";
|
button.className = "header-menu-item";
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
|
|
||||||
const left = document.createElement("span");
|
const left = document.createElement("span");
|
||||||
left.textContent = label;
|
left.textContent = label;
|
||||||
|
|
||||||
const right = document.createElement("small");
|
const right = document.createElement("small");
|
||||||
right.textContent = hint;
|
right.textContent = hint;
|
||||||
|
|
||||||
button.append(left, right);
|
button.append(left, right);
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
Glow.closeHeaderMenu?.();
|
Glow.closeHeaderMenu?.();
|
||||||
@ -202,20 +58,17 @@ Glow.renderHeaderMenu = () => {
|
|||||||
});
|
});
|
||||||
list.append(button);
|
list.append(button);
|
||||||
};
|
};
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "My profile",
|
label: "My profile",
|
||||||
hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "",
|
hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "",
|
||||||
onClick: () => Glow.openMyProfile?.(),
|
onClick: () => Glow.openMyProfile?.(),
|
||||||
hidden: !Glow.state.currentUser
|
hidden: !Glow.state.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Settings",
|
label: "Settings",
|
||||||
hint: "Theme, CSS, profile",
|
hint: "Theme, CSS, profile",
|
||||||
onClick: () => Glow.openSettings?.()
|
onClick: () => Glow.openSettings?.()
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Direct messages",
|
label: "Direct messages",
|
||||||
hint: Glow.state.currentUser ? "Private chat" : "",
|
hint: Glow.state.currentUser ? "Private chat" : "",
|
||||||
@ -227,28 +80,24 @@ Glow.renderHeaderMenu = () => {
|
|||||||
},
|
},
|
||||||
hidden: !Glow.state.currentUser
|
hidden: !Glow.state.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Create team",
|
label: "Create team",
|
||||||
hint: "Up to 3 max",
|
hint: "Up to 3 max",
|
||||||
onClick: () => Glow.openCreateTeamDialog?.(),
|
onClick: () => Glow.openCreateTeamDialog?.(),
|
||||||
hidden: !Glow.state.currentUser
|
hidden: !Glow.state.currentUser
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Edit current team",
|
label: "Edit current team",
|
||||||
hint: Glow.state.activeTeam?.can_manage ? "Manage" : "",
|
hint: Glow.state.activeTeam?.can_manage ? "Manage" : "",
|
||||||
onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam),
|
onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam),
|
||||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Add channel",
|
label: "Add channel",
|
||||||
hint: Glow.state.activeTeam?.name || "",
|
hint: Glow.state.activeTeam?.name || "",
|
||||||
onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam),
|
onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam),
|
||||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Edit current channel",
|
label: "Edit current channel",
|
||||||
hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "",
|
hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "",
|
||||||
@ -262,21 +111,18 @@ Glow.renderHeaderMenu = () => {
|
|||||||
!(Glow.state.activeChannel || Glow.state.activeRoom) ||
|
!(Glow.state.activeChannel || Glow.state.activeRoom) ||
|
||||||
Glow.state.activeView === "dm"
|
Glow.state.activeView === "dm"
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Manage team members",
|
label: "Manage team members",
|
||||||
hint: Glow.state.activeTeam?.can_manage ? "Members" : "",
|
hint: Glow.state.activeTeam?.can_manage ? "Members" : "",
|
||||||
onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam),
|
onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam),
|
||||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Create team invite",
|
label: "Create team invite",
|
||||||
hint: Glow.state.activeTeam?.can_manage ? "Share" : "",
|
hint: Glow.state.activeTeam?.can_manage ? "Share" : "",
|
||||||
onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam),
|
onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam),
|
||||||
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage
|
||||||
});
|
});
|
||||||
|
|
||||||
addItem({
|
addItem({
|
||||||
label: "Delete current channel",
|
label: "Delete current channel",
|
||||||
hint: Glow.state.activeChannel?.name || "",
|
hint: Glow.state.activeChannel?.name || "",
|
||||||
@ -309,28 +155,18 @@ Glow.toggleHeaderMenu = () => {
|
|||||||
Glow.openSettings = async () => {
|
Glow.openSettings = async () => {
|
||||||
Glow.state.activeView = "settings";
|
Glow.state.activeView = "settings";
|
||||||
Glow.updateViewUI();
|
Glow.updateViewUI();
|
||||||
|
if (!Glow.state.settings && Glow.state.currentUser) {
|
||||||
if (!Glow.state.settingsSaved && Glow.state.currentUser) {
|
|
||||||
await Glow.loadMySettings();
|
await Glow.loadMySettings();
|
||||||
} else if (!Glow.state.settingsDraft) {
|
|
||||||
Glow.initializeSettingsDraft(
|
|
||||||
Glow.state.settingsSaved || Glow.cloneSettingsState({})
|
|
||||||
);
|
|
||||||
Glow.previewSettingsDraft();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Glow.renderSettingsView();
|
Glow.renderSettingsView();
|
||||||
Glow.syncSettingsSavePanel();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.renderNameColorPreview = value => {
|
Glow.renderNameColorPreview = value => {
|
||||||
const wrap = document.createElement("div");
|
const wrap = document.createElement("div");
|
||||||
wrap.className = "settings-row";
|
wrap.className = "settings-row";
|
||||||
|
|
||||||
const label = document.createElement("div");
|
const label = document.createElement("div");
|
||||||
label.className = "header-subtitle";
|
label.className = "header-subtitle";
|
||||||
label.textContent = "Preview";
|
label.textContent = "Preview";
|
||||||
|
|
||||||
const preview = document.createElement("div");
|
const preview = document.createElement("div");
|
||||||
preview.className = "name-color-preview";
|
preview.className = "name-color-preview";
|
||||||
preview.textContent =
|
preview.textContent =
|
||||||
@ -338,69 +174,118 @@ Glow.renderNameColorPreview = value => {
|
|||||||
preview.style.color =
|
preview.style.color =
|
||||||
Glow.normalizeNameColor(value) ||
|
Glow.normalizeNameColor(value) ||
|
||||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||||
|
|
||||||
wrap.append(label, preview);
|
wrap.append(label, preview);
|
||||||
return wrap;
|
return wrap;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.renderChoicePills = ({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
options,
|
|
||||||
onChange
|
|
||||||
}) => {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "settings-row";
|
|
||||||
|
|
||||||
const title = document.createElement("div");
|
|
||||||
title.className = "dialog-label";
|
|
||||||
title.textContent = label;
|
|
||||||
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "settings-theme-row";
|
|
||||||
|
|
||||||
for (const option of options) {
|
|
||||||
const button = document.createElement("button");
|
|
||||||
button.className = `theme-pill${value === option.value ? " active" : ""}`;
|
|
||||||
button.type = "button";
|
|
||||||
button.textContent = option.label;
|
|
||||||
button.addEventListener("click", () => onChange(option.value));
|
|
||||||
row.append(button);
|
|
||||||
}
|
|
||||||
|
|
||||||
wrap.append(title, row);
|
|
||||||
return wrap;
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.renderSettingsView = () => {
|
Glow.renderSettingsView = () => {
|
||||||
const box = Glow.els.profileBox;
|
const box = Glow.els.profileBox;
|
||||||
box.replaceChildren();
|
box.replaceChildren();
|
||||||
|
|
||||||
const draft = Glow.state.settingsDraft || Glow.cloneSettingsState({});
|
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "settings-card";
|
card.className = "settings-card";
|
||||||
|
|
||||||
const identity = document.createElement("section");
|
const appearance = document.createElement("section");
|
||||||
identity.className = "settings-section";
|
appearance.className = "settings-section";
|
||||||
|
const aTitle = document.createElement("h2");
|
||||||
|
aTitle.textContent = "Appearance";
|
||||||
|
const aText = document.createElement("p");
|
||||||
|
aText.textContent = "Set your theme mode, accent color, username color, profile background, and banner color.";
|
||||||
|
|
||||||
const iTitle = document.createElement("h2");
|
const themeRow = document.createElement("div");
|
||||||
iTitle.textContent = "Identity";
|
themeRow.className = "settings-theme-row";
|
||||||
|
const currentTheme =
|
||||||
|
Glow.state.settings?.theme_mode ||
|
||||||
|
localStorage.getItem("glow_theme_mode") ||
|
||||||
|
"dark";
|
||||||
|
|
||||||
const iText = document.createElement("p");
|
const darkButton = document.createElement("button");
|
||||||
iText.textContent = "Adjust how your name and profile presence appear across the app.";
|
darkButton.className = `theme-pill${currentTheme !== "light" ? " active" : ""}`;
|
||||||
|
darkButton.type = "button";
|
||||||
|
darkButton.textContent = "Dark";
|
||||||
|
const lightButton = document.createElement("button");
|
||||||
|
lightButton.className = `theme-pill${currentTheme === "light" ? " active" : ""}`;
|
||||||
|
lightButton.type = "button";
|
||||||
|
lightButton.textContent = "Light";
|
||||||
|
|
||||||
|
darkButton.addEventListener("click", () => {
|
||||||
|
Glow.applyThemeMode("dark");
|
||||||
|
Glow.state.settings ||= {};
|
||||||
|
Glow.state.settings.theme_mode = "dark";
|
||||||
|
Glow.renderSettingsView();
|
||||||
|
});
|
||||||
|
lightButton.addEventListener("click", () => {
|
||||||
|
Glow.applyThemeMode("light");
|
||||||
|
Glow.state.settings ||= {};
|
||||||
|
Glow.state.settings.theme_mode = "light";
|
||||||
|
Glow.renderSettingsView();
|
||||||
|
});
|
||||||
|
themeRow.append(darkButton, lightButton);
|
||||||
|
|
||||||
|
const accentWrap = document.createElement("div");
|
||||||
|
accentWrap.className = "settings-row";
|
||||||
|
const accentLabel = document.createElement("label");
|
||||||
|
accentLabel.className = "dialog-label";
|
||||||
|
accentLabel.textContent = "Accent color";
|
||||||
|
const accentInput = document.createElement("input");
|
||||||
|
accentInput.type = "color";
|
||||||
|
accentInput.className = "accent-color-input";
|
||||||
|
accentInput.value =
|
||||||
|
Glow.normalizeAccentColor(
|
||||||
|
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||||
|
) || Glow.DEFAULT_ACCENT_COLOR;
|
||||||
|
accentInput.addEventListener("input", () => {
|
||||||
|
Glow.applyAccentColor(accentInput.value);
|
||||||
|
Glow.state.settings ||= {};
|
||||||
|
Glow.state.settings.accent_color = accentInput.value;
|
||||||
|
});
|
||||||
|
accentLabel.append(accentInput);
|
||||||
|
accentWrap.append(accentLabel);
|
||||||
|
|
||||||
|
const backgroundWrap = document.createElement("div");
|
||||||
|
backgroundWrap.className = "settings-row";
|
||||||
|
const backgroundLabel = document.createElement("label");
|
||||||
|
backgroundLabel.className = "dialog-label";
|
||||||
|
backgroundLabel.textContent = "Profile background color";
|
||||||
|
const backgroundInput = document.createElement("input");
|
||||||
|
backgroundInput.type = "color";
|
||||||
|
backgroundInput.className = "accent-color-input";
|
||||||
|
backgroundInput.value =
|
||||||
|
Glow.normalizeAccentColor(Glow.state.settings?.profile_background_color) || "#121214";
|
||||||
|
backgroundInput.addEventListener("input", () => {
|
||||||
|
Glow.state.settings ||= {};
|
||||||
|
Glow.state.settings.profile_background_color = backgroundInput.value;
|
||||||
|
});
|
||||||
|
backgroundLabel.append(backgroundInput);
|
||||||
|
backgroundWrap.append(backgroundLabel);
|
||||||
|
|
||||||
|
const bannerWrap = document.createElement("div");
|
||||||
|
bannerWrap.className = "settings-row";
|
||||||
|
const bannerLabel = document.createElement("label");
|
||||||
|
bannerLabel.className = "dialog-label";
|
||||||
|
bannerLabel.textContent = "Profile banner color";
|
||||||
|
const bannerInput = document.createElement("input");
|
||||||
|
bannerInput.type = "color";
|
||||||
|
bannerInput.className = "accent-color-input";
|
||||||
|
bannerInput.value =
|
||||||
|
Glow.normalizeAccentColor(Glow.state.settings?.profile_banner_color) || "#3a3140";
|
||||||
|
bannerInput.addEventListener("input", () => {
|
||||||
|
Glow.state.settings ||= {};
|
||||||
|
Glow.state.settings.profile_banner_color = bannerInput.value;
|
||||||
|
});
|
||||||
|
bannerLabel.append(bannerInput);
|
||||||
|
bannerWrap.append(bannerLabel);
|
||||||
|
|
||||||
const nameColorWrap = document.createElement("div");
|
const nameColorWrap = document.createElement("div");
|
||||||
nameColorWrap.className = "settings-row";
|
nameColorWrap.className = "settings-row";
|
||||||
|
|
||||||
const nameColorLabel = document.createElement("label");
|
const nameColorLabel = document.createElement("label");
|
||||||
nameColorLabel.className = "dialog-label";
|
nameColorLabel.className = "dialog-label";
|
||||||
nameColorLabel.textContent = "Username color";
|
nameColorLabel.textContent = "Username color";
|
||||||
|
|
||||||
const nameColorInput = document.createElement("input");
|
const nameColorInput = document.createElement("input");
|
||||||
nameColorInput.type = "color";
|
nameColorInput.type = "color";
|
||||||
nameColorInput.className = "accent-color-input";
|
nameColorInput.className = "accent-color-input";
|
||||||
nameColorInput.value =
|
nameColorInput.value =
|
||||||
Glow.normalizeNameColor(draft.name_color) ||
|
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||||
|
|
||||||
const nameColorText = document.createElement("input");
|
const nameColorText = document.createElement("input");
|
||||||
@ -408,30 +293,43 @@ Glow.renderSettingsView = () => {
|
|||||||
nameColorText.type = "text";
|
nameColorText.type = "text";
|
||||||
nameColorText.maxLength = 7;
|
nameColorText.maxLength = 7;
|
||||||
nameColorText.value =
|
nameColorText.value =
|
||||||
Glow.normalizeNameColor(draft.name_color) || "";
|
Glow.normalizeNameColor(Glow.state.settings?.name_color) || "";
|
||||||
nameColorText.placeholder = "#7dd3fc";
|
nameColorText.placeholder = "#7dd3fc";
|
||||||
|
|
||||||
const nameRow = document.createElement("div");
|
const nameRow = document.createElement("div");
|
||||||
nameRow.className = "accent-picker-row";
|
nameRow.className = "accent-picker-row";
|
||||||
|
|
||||||
const previewSwatch = document.createElement("span");
|
const previewSwatch = document.createElement("span");
|
||||||
previewSwatch.className = "accent-preview";
|
previewSwatch.className = "accent-preview";
|
||||||
previewSwatch.style.background =
|
previewSwatch.style.background =
|
||||||
Glow.normalizeNameColor(draft.name_color) ||
|
Glow.normalizeNameColor(Glow.state.settings?.name_color) ||
|
||||||
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||||
|
|
||||||
|
const help = document.createElement("div");
|
||||||
|
help.className = "header-subtitle";
|
||||||
|
help.textContent =
|
||||||
|
`Leave empty to use your role default (${Glow.roleDefaultNameColor(Glow.state.currentUser?.role)}).`;
|
||||||
|
|
||||||
|
const updatePreview = value => {
|
||||||
|
const normalized = Glow.normalizeNameColor(value);
|
||||||
|
const finalColor =
|
||||||
|
normalized || Glow.roleDefaultNameColor(Glow.state.currentUser?.role);
|
||||||
|
previewSwatch.style.background = finalColor;
|
||||||
|
return finalColor;
|
||||||
|
};
|
||||||
|
|
||||||
nameColorInput.addEventListener("input", () => {
|
nameColorInput.addEventListener("input", () => {
|
||||||
nameColorText.value = nameColorInput.value;
|
nameColorText.value = nameColorInput.value;
|
||||||
Glow.updateSettingsDraft({
|
Glow.state.settings ||= {};
|
||||||
name_color: nameColorInput.value
|
Glow.state.settings.name_color = nameColorInput.value;
|
||||||
});
|
updatePreview(nameColorInput.value);
|
||||||
|
Glow.renderSettingsView();
|
||||||
});
|
});
|
||||||
|
|
||||||
nameColorText.addEventListener("input", () => {
|
nameColorText.addEventListener("input", () => {
|
||||||
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
|
const normalized = Glow.normalizeNameColor(nameColorText.value.trim());
|
||||||
Glow.updateSettingsDraft({
|
Glow.state.settings ||= {};
|
||||||
name_color: normalized || null
|
Glow.state.settings.name_color = normalized || null;
|
||||||
});
|
updatePreview(nameColorText.value.trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetNameColor = document.createElement("button");
|
const resetNameColor = document.createElement("button");
|
||||||
@ -439,9 +337,8 @@ Glow.renderSettingsView = () => {
|
|||||||
resetNameColor.type = "button";
|
resetNameColor.type = "button";
|
||||||
resetNameColor.textContent = "Use role default";
|
resetNameColor.textContent = "Use role default";
|
||||||
resetNameColor.addEventListener("click", () => {
|
resetNameColor.addEventListener("click", () => {
|
||||||
Glow.updateSettingsDraft({
|
Glow.state.settings ||= {};
|
||||||
name_color: null
|
Glow.state.settings.name_color = null;
|
||||||
});
|
|
||||||
Glow.renderSettingsView();
|
Glow.renderSettingsView();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -449,213 +346,106 @@ Glow.renderSettingsView = () => {
|
|||||||
nameColorLabel.append(nameRow);
|
nameColorLabel.append(nameRow);
|
||||||
nameColorWrap.append(
|
nameColorWrap.append(
|
||||||
nameColorLabel,
|
nameColorLabel,
|
||||||
Glow.renderNameColorPreview(draft.name_color),
|
help,
|
||||||
|
Glow.renderNameColorPreview(Glow.state.settings?.name_color),
|
||||||
resetNameColor
|
resetNameColor
|
||||||
);
|
);
|
||||||
|
|
||||||
const profileVisibility = Glow.renderChoicePills({
|
appearance.append(
|
||||||
label: "Profile visibility",
|
aTitle,
|
||||||
value: draft.profile_visibility || "public",
|
aText,
|
||||||
options: [
|
themeRow,
|
||||||
{ value: "public", label: "Public" },
|
accentWrap,
|
||||||
{ value: "members", label: "Members" },
|
backgroundWrap,
|
||||||
{ value: "private", label: "Private" }
|
bannerWrap,
|
||||||
],
|
nameColorWrap
|
||||||
onChange: value => {
|
|
||||||
Glow.updateSettingsDraft({
|
|
||||||
profile_visibility: value
|
|
||||||
});
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const lastActiveVisibility = Glow.renderChoicePills({
|
|
||||||
label: "Last active visibility",
|
|
||||||
value: draft.last_active_visibility || "everyone",
|
|
||||||
options: [
|
|
||||||
{ value: "everyone", label: "Everyone" },
|
|
||||||
{ value: "members", label: "Members" },
|
|
||||||
{ value: "nobody", label: "Nobody" }
|
|
||||||
],
|
|
||||||
onChange: value => {
|
|
||||||
Glow.updateSettingsDraft({
|
|
||||||
last_active_visibility: value
|
|
||||||
});
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const presenceMode = Glow.renderChoicePills({
|
|
||||||
label: "Presence mode",
|
|
||||||
value: draft.presence_mode || "online",
|
|
||||||
options: [
|
|
||||||
{ value: "online", label: "Online" },
|
|
||||||
{ value: "silent", label: "Silent" },
|
|
||||||
{ value: "focus", label: "Focus" },
|
|
||||||
{ value: "dnd", label: "Dnd" }
|
|
||||||
],
|
|
||||||
onChange: value => {
|
|
||||||
Glow.updateSettingsDraft({
|
|
||||||
presence_mode: value
|
|
||||||
});
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
identity.append(
|
|
||||||
iTitle,
|
|
||||||
iText,
|
|
||||||
nameColorWrap,
|
|
||||||
profileVisibility,
|
|
||||||
lastActiveVisibility,
|
|
||||||
presenceMode
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const appearance = document.createElement("section");
|
const presenceSection = document.createElement("section");
|
||||||
appearance.className = "settings-section";
|
presenceSection.className = "settings-section";
|
||||||
|
const pTitle = document.createElement("h2");
|
||||||
|
pTitle.textContent = "Presence & privacy";
|
||||||
|
const pText = document.createElement("p");
|
||||||
|
pText.textContent = "Control who can view your profile and last active time, and set your presence mode.";
|
||||||
|
|
||||||
const aTitle = document.createElement("h2");
|
const profileVisibility = document.createElement("input");
|
||||||
aTitle.textContent = "Appearance";
|
profileVisibility.className = "field";
|
||||||
|
profileVisibility.value = Glow.state.settings?.profile_visibility || "public";
|
||||||
|
profileVisibility.placeholder = "public | members | private";
|
||||||
|
|
||||||
const aText = document.createElement("p");
|
const lastActiveVisibility = document.createElement("input");
|
||||||
aText.textContent = "Set theme and accent color.";
|
lastActiveVisibility.className = "field";
|
||||||
|
lastActiveVisibility.value = Glow.state.settings?.last_active_visibility || "everyone";
|
||||||
|
lastActiveVisibility.placeholder = "everyone | members | nobody";
|
||||||
|
|
||||||
const themeRow = document.createElement("div");
|
const presenceMode = document.createElement("input");
|
||||||
themeRow.className = "settings-theme-row";
|
presenceMode.className = "field";
|
||||||
|
presenceMode.value = Glow.state.settings?.presence_mode || Glow.state.currentUser?.presence_mode || "online";
|
||||||
|
presenceMode.placeholder = "online | silent | focus | dnd";
|
||||||
|
|
||||||
const darkButton = document.createElement("button");
|
profileVisibility.addEventListener("input", () => {
|
||||||
darkButton.className = `theme-pill${draft.theme_mode !== "light" ? " active" : ""}`;
|
Glow.state.settings ||= {};
|
||||||
darkButton.type = "button";
|
Glow.state.settings.profile_visibility = profileVisibility.value.trim().toLowerCase();
|
||||||
darkButton.textContent = "Dark";
|
});
|
||||||
|
lastActiveVisibility.addEventListener("input", () => {
|
||||||
const lightButton = document.createElement("button");
|
Glow.state.settings ||= {};
|
||||||
lightButton.className = `theme-pill${draft.theme_mode === "light" ? " active" : ""}`;
|
Glow.state.settings.last_active_visibility = lastActiveVisibility.value.trim().toLowerCase();
|
||||||
lightButton.type = "button";
|
});
|
||||||
lightButton.textContent = "Light";
|
presenceMode.addEventListener("input", () => {
|
||||||
|
Glow.state.settings ||= {};
|
||||||
darkButton.addEventListener("click", () => {
|
Glow.state.settings.presence_mode = presenceMode.value.trim().toLowerCase();
|
||||||
Glow.updateSettingsDraft({ theme_mode: "dark" });
|
|
||||||
Glow.renderSettingsView();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
lightButton.addEventListener("click", () => {
|
presenceSection.append(
|
||||||
Glow.updateSettingsDraft({ theme_mode: "light" });
|
pTitle,
|
||||||
Glow.renderSettingsView();
|
pText,
|
||||||
});
|
(() => {
|
||||||
|
const label = document.createElement("label");
|
||||||
themeRow.append(darkButton, lightButton);
|
label.className = "dialog-label";
|
||||||
|
label.textContent = "Profile visibility";
|
||||||
const accentWrap = document.createElement("div");
|
label.append(profileVisibility);
|
||||||
accentWrap.className = "settings-row";
|
return label;
|
||||||
|
})(),
|
||||||
const accentLabel = document.createElement("label");
|
(() => {
|
||||||
accentLabel.className = "dialog-label";
|
const label = document.createElement("label");
|
||||||
accentLabel.textContent = "Accent color";
|
label.className = "dialog-label";
|
||||||
|
label.textContent = "Last active visibility";
|
||||||
const accentInput = document.createElement("input");
|
label.append(lastActiveVisibility);
|
||||||
accentInput.type = "color";
|
return label;
|
||||||
accentInput.className = "accent-color-input";
|
})(),
|
||||||
accentInput.value =
|
(() => {
|
||||||
Glow.normalizeAccentColor(draft.accent_color) ||
|
const label = document.createElement("label");
|
||||||
Glow.DEFAULT_ACCENT_COLOR;
|
label.className = "dialog-label";
|
||||||
|
label.textContent = "Presence mode";
|
||||||
accentInput.addEventListener("input", () => {
|
label.append(presenceMode);
|
||||||
Glow.updateSettingsDraft({
|
return label;
|
||||||
accent_color: accentInput.value
|
})()
|
||||||
});
|
);
|
||||||
});
|
|
||||||
|
|
||||||
accentLabel.append(accentInput);
|
|
||||||
accentWrap.append(accentLabel);
|
|
||||||
|
|
||||||
appearance.append(aTitle, aText, themeRow, accentWrap);
|
|
||||||
|
|
||||||
const profileVisuals = document.createElement("section");
|
|
||||||
profileVisuals.className = "settings-section";
|
|
||||||
|
|
||||||
const pvTitle = document.createElement("h2");
|
|
||||||
pvTitle.textContent = "Profile visuals";
|
|
||||||
|
|
||||||
const pvText = document.createElement("p");
|
|
||||||
pvText.textContent = "Adjust background and banner colors used in your profile view.";
|
|
||||||
|
|
||||||
const backgroundWrap = document.createElement("div");
|
|
||||||
backgroundWrap.className = "settings-row";
|
|
||||||
|
|
||||||
const backgroundLabel = document.createElement("label");
|
|
||||||
backgroundLabel.className = "dialog-label";
|
|
||||||
backgroundLabel.textContent = "Profile background color";
|
|
||||||
|
|
||||||
const backgroundInput = document.createElement("input");
|
|
||||||
backgroundInput.type = "color";
|
|
||||||
backgroundInput.className = "accent-color-input";
|
|
||||||
backgroundInput.value =
|
|
||||||
Glow.normalizeAccentColor(draft.profile_background_color) || "#121214";
|
|
||||||
|
|
||||||
backgroundInput.addEventListener("input", () => {
|
|
||||||
Glow.updateSettingsDraft({
|
|
||||||
profile_background_color: backgroundInput.value
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
backgroundLabel.append(backgroundInput);
|
|
||||||
backgroundWrap.append(backgroundLabel);
|
|
||||||
|
|
||||||
const bannerWrap = document.createElement("div");
|
|
||||||
bannerWrap.className = "settings-row";
|
|
||||||
|
|
||||||
const bannerLabel = document.createElement("label");
|
|
||||||
bannerLabel.className = "dialog-label";
|
|
||||||
bannerLabel.textContent = "Profile banner color";
|
|
||||||
|
|
||||||
const bannerInput = document.createElement("input");
|
|
||||||
bannerInput.type = "color";
|
|
||||||
bannerInput.className = "accent-color-input";
|
|
||||||
bannerInput.value =
|
|
||||||
Glow.normalizeAccentColor(draft.profile_banner_color) || "#3a3140";
|
|
||||||
|
|
||||||
bannerInput.addEventListener("input", () => {
|
|
||||||
Glow.updateSettingsDraft({
|
|
||||||
profile_banner_color: bannerInput.value
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
bannerLabel.append(bannerInput);
|
|
||||||
bannerWrap.append(bannerLabel);
|
|
||||||
|
|
||||||
profileVisuals.append(pvTitle, pvText, backgroundWrap, bannerWrap);
|
|
||||||
|
|
||||||
const cssSection = document.createElement("section");
|
const cssSection = document.createElement("section");
|
||||||
cssSection.className = "settings-section";
|
cssSection.className = "settings-section";
|
||||||
|
|
||||||
const cTitle = document.createElement("h2");
|
const cTitle = document.createElement("h2");
|
||||||
cTitle.textContent = "Custom CSS";
|
cTitle.textContent = "Custom CSS";
|
||||||
|
|
||||||
const cText = document.createElement("p");
|
const cText = document.createElement("p");
|
||||||
cText.textContent =
|
cText.textContent =
|
||||||
"Optional CSS applied locally for preview and saved to your account when supported.";
|
"Optional CSS applied only for your account in this browser session and saved to your settings. \n ex: *,html,body{font-size:95%;text-transform:lowercase;}";
|
||||||
|
|
||||||
const cssInput = document.createElement("textarea");
|
const cssInput = document.createElement("textarea");
|
||||||
cssInput.className = "field settings-custom-css";
|
cssInput.className = "field settings-custom-css";
|
||||||
cssInput.placeholder = "/* Custom CSS */";
|
cssInput.placeholder = "/* Custom CSS */";
|
||||||
cssInput.value = draft.custom_css || "";
|
cssInput.value = Glow.state.settings?.custom_css || "";
|
||||||
cssInput.addEventListener("input", () => {
|
cssInput.addEventListener("input", () => {
|
||||||
Glow.updateSettingsDraft({
|
Glow.applyCustomCss(cssInput.value);
|
||||||
custom_css: cssInput.value
|
Glow.state.settings ||= {};
|
||||||
});
|
Glow.state.settings.custom_css = cssInput.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
cssSection.append(cTitle, cText, cssInput);
|
cssSection.append(cTitle, cText, cssInput);
|
||||||
|
|
||||||
const accountSection = document.createElement("section");
|
const accountSection = document.createElement("section");
|
||||||
accountSection.className = "settings-section";
|
accountSection.className = "settings-section";
|
||||||
|
|
||||||
const accountTitle = document.createElement("h2");
|
const accountTitle = document.createElement("h2");
|
||||||
accountTitle.textContent = "Account";
|
accountTitle.textContent = "Account";
|
||||||
|
|
||||||
const accountText = document.createElement("p");
|
const accountText = document.createElement("p");
|
||||||
accountText.textContent = "Open your profile editor or reset visual customizations.";
|
accountText.textContent = "Update your public profile and application preferences.";
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "settings-actions";
|
actions.className = "settings-actions";
|
||||||
|
|
||||||
@ -667,57 +457,61 @@ Glow.renderSettingsView = () => {
|
|||||||
Glow.openMyProfile?.(true);
|
Glow.openMyProfile?.(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveSettings = document.createElement("button");
|
||||||
|
saveSettings.className = "button";
|
||||||
|
saveSettings.type = "button";
|
||||||
|
saveSettings.textContent = "Save settings";
|
||||||
|
saveSettings.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
accent_color:
|
||||||
|
Glow.normalizeAccentColor(
|
||||||
|
Glow.state.settings?.accent_color || Glow.state.accentColor
|
||||||
|
) || Glow.DEFAULT_ACCENT_COLOR,
|
||||||
|
name_color:
|
||||||
|
Glow.normalizeNameColor(Glow.state.settings?.name_color) || null,
|
||||||
|
custom_css: Glow.state.settings?.custom_css || "",
|
||||||
|
theme_mode:
|
||||||
|
Glow.state.settings?.theme_mode ||
|
||||||
|
localStorage.getItem("glow_theme_mode") ||
|
||||||
|
"dark",
|
||||||
|
profile_background_color:
|
||||||
|
Glow.normalizeAccentColor(Glow.state.settings?.profile_background_color) || null,
|
||||||
|
profile_banner_color:
|
||||||
|
Glow.normalizeAccentColor(Glow.state.settings?.profile_banner_color) || null,
|
||||||
|
profile_visibility:
|
||||||
|
Glow.state.settings?.profile_visibility || "public",
|
||||||
|
last_active_visibility:
|
||||||
|
Glow.state.settings?.last_active_visibility || "everyone",
|
||||||
|
presence_mode:
|
||||||
|
Glow.state.settings?.presence_mode || "online"
|
||||||
|
};
|
||||||
|
const data = await Glow.apiUpdateMySettings(payload);
|
||||||
|
Glow.state.settings = data.settings || payload;
|
||||||
|
if (Glow.state.currentUser) {
|
||||||
|
Glow.state.currentUser.presence_mode = Glow.state.settings.presence_mode || "online";
|
||||||
|
}
|
||||||
|
Glow.showToast("Settings saved.");
|
||||||
|
Glow.renderSettingsView();
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not save settings.", "error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const resetAccent = document.createElement("button");
|
const resetAccent = document.createElement("button");
|
||||||
resetAccent.className = "button secondary";
|
resetAccent.className = "button secondary";
|
||||||
resetAccent.type = "button";
|
resetAccent.type = "button";
|
||||||
resetAccent.textContent = "Reset accent";
|
resetAccent.textContent = "Reset accent";
|
||||||
resetAccent.addEventListener("click", () => {
|
resetAccent.addEventListener("click", () => {
|
||||||
Glow.updateSettingsDraft({
|
Glow.resetAccentColor();
|
||||||
accent_color: Glow.DEFAULT_ACCENT_COLOR
|
Glow.state.settings ||= {};
|
||||||
});
|
Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR;
|
||||||
Glow.renderSettingsView();
|
Glow.renderSettingsView();
|
||||||
});
|
});
|
||||||
|
|
||||||
const discard = document.createElement("button");
|
actions.append(editProfile, saveSettings, resetAccent);
|
||||||
discard.className = "button secondary";
|
|
||||||
discard.type = "button";
|
|
||||||
discard.textContent = "Discard changes";
|
|
||||||
discard.addEventListener("click", () => {
|
|
||||||
Glow.discardSettingsDraft();
|
|
||||||
});
|
|
||||||
|
|
||||||
actions.append(editProfile, resetAccent, discard);
|
|
||||||
accountSection.append(accountTitle, accountText, actions);
|
accountSection.append(accountTitle, accountText, actions);
|
||||||
|
|
||||||
card.append(identity, appearance, profileVisuals, cssSection, accountSection);
|
card.append(appearance, presenceSection, cssSection, accountSection);
|
||||||
box.append(card);
|
box.append(card);
|
||||||
Glow.syncSettingsSavePanel();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.els.settingsSaveButton?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await Glow.saveSettingsDraft();
|
|
||||||
} catch (error) {
|
|
||||||
Glow.showToast(error.message || "Could not save settings.", "error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.settingsCloseButton?.addEventListener("click", async () => {
|
|
||||||
if (!Glow.isSettingsDirty?.()) {
|
|
||||||
await Glow.openFeed?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = await Glow.openDialog({
|
|
||||||
title: "Leave settings?",
|
|
||||||
body: "You have unsaved changes.",
|
|
||||||
confirmText: "Discard changes",
|
|
||||||
cancelText: "Stay",
|
|
||||||
danger: true
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!values) return;
|
|
||||||
|
|
||||||
Glow.discardSettingsDraft();
|
|
||||||
await Glow.openFeed?.();
|
|
||||||
});
|
|
||||||
@ -23,135 +23,6 @@ Glow.renderProfileBody = profile => {
|
|||||||
return wrap;
|
return wrap;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.profileCanShowPresenceBadge = profile => {
|
|
||||||
if (!profile) return false;
|
|
||||||
if (profile.is_self) return true;
|
|
||||||
if (typeof profile.show_presence_badge === "boolean") {
|
|
||||||
return profile.show_presence_badge;
|
|
||||||
}
|
|
||||||
return Glow.state.profilePresenceBadgeVisible !== false;
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileWebsiteMeta = website => {
|
|
||||||
return Glow.websiteBrandMeta?.(website) || {
|
|
||||||
host: "",
|
|
||||||
brand: "web",
|
|
||||||
label: "website",
|
|
||||||
iconText: "â—‹"
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileOpenMediaPicker = kind => {
|
|
||||||
if (kind === "avatar") {
|
|
||||||
Glow.els.avatarUploadInput?.click();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (kind === "banner") {
|
|
||||||
Glow.els.bannerUploadInput?.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileResetCropState = () => {
|
|
||||||
const crop = Glow.state.imageCrop;
|
|
||||||
if (crop.previewUrl) {
|
|
||||||
try {
|
|
||||||
URL.revokeObjectURL(crop.previewUrl);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Glow.state.imageCrop = {
|
|
||||||
open: false,
|
|
||||||
kind: "",
|
|
||||||
file: null,
|
|
||||||
previewUrl: "",
|
|
||||||
zoom: 1,
|
|
||||||
autoCrop: false
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Glow.els.imageCropOverlay) {
|
|
||||||
Glow.els.imageCropOverlay.classList.add("hidden");
|
|
||||||
}
|
|
||||||
if (Glow.els.imageCropPreview) {
|
|
||||||
Glow.els.imageCropPreview.replaceChildren();
|
|
||||||
}
|
|
||||||
if (Glow.els.imageCropZoom) {
|
|
||||||
Glow.els.imageCropZoom.value = "1";
|
|
||||||
}
|
|
||||||
if (Glow.els.imageCropAuto) {
|
|
||||||
Glow.els.imageCropAuto.checked = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileOpenCropDialog = (kind, file) => {
|
|
||||||
if (!file || !kind) return;
|
|
||||||
|
|
||||||
Glow.profileResetCropState();
|
|
||||||
|
|
||||||
const previewUrl = URL.createObjectURL(file);
|
|
||||||
Glow.state.imageCrop = {
|
|
||||||
open: true,
|
|
||||||
kind,
|
|
||||||
file,
|
|
||||||
previewUrl,
|
|
||||||
zoom: 1,
|
|
||||||
autoCrop: false
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Glow.els.imageCropPreview) {
|
|
||||||
const image = document.createElement("img");
|
|
||||||
image.src = previewUrl;
|
|
||||||
image.alt = `${kind} preview`;
|
|
||||||
image.className = "crop-preview-image";
|
|
||||||
Glow.els.imageCropPreview.replaceChildren(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
Glow.els.imageCropOverlay?.classList.remove("hidden");
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileApplyCropSelection = () => {
|
|
||||||
const crop = Glow.state.imageCrop;
|
|
||||||
if (!crop?.file || !crop?.kind) {
|
|
||||||
Glow.profileResetCropState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (crop.kind === "avatar") {
|
|
||||||
Glow.state.profileMediaDraft.avatarFile = crop.file;
|
|
||||||
} else if (crop.kind === "banner") {
|
|
||||||
Glow.state.profileMediaDraft.bannerFile = crop.file;
|
|
||||||
}
|
|
||||||
|
|
||||||
Glow.showToast(
|
|
||||||
crop.kind === "avatar"
|
|
||||||
? "Avatar image selected. Save profile changes to keep it."
|
|
||||||
: "Banner image selected. Save profile changes to keep it."
|
|
||||||
);
|
|
||||||
|
|
||||||
Glow.profileResetCropState();
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.profileBuildWebsiteBadge = website => {
|
|
||||||
if (!website) return null;
|
|
||||||
|
|
||||||
const meta = Glow.profileWebsiteMeta(website);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.className = `profile-website-badge brand-${meta.brand}`;
|
|
||||||
link.href = website;
|
|
||||||
link.target = "_blank";
|
|
||||||
link.rel = "noopener noreferrer";
|
|
||||||
|
|
||||||
const icon = document.createElement("span");
|
|
||||||
icon.className = "profile-website-badge-icon";
|
|
||||||
icon.textContent = meta.iconText;
|
|
||||||
|
|
||||||
const text = document.createElement("span");
|
|
||||||
text.className = "profile-website-badge-text";
|
|
||||||
text.textContent = meta.label;
|
|
||||||
|
|
||||||
link.append(icon, text);
|
|
||||||
return link;
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.openProfile = async username => {
|
Glow.openProfile = async username => {
|
||||||
Glow.state.activeView = "profile";
|
Glow.state.activeView = "profile";
|
||||||
Glow.state.activeProfile = null;
|
Glow.state.activeProfile = null;
|
||||||
@ -227,17 +98,8 @@ Glow.renderProfileView = (editing = false) => {
|
|||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "profile-card";
|
card.className = "profile-card";
|
||||||
|
|
||||||
const banner = document.createElement(profile.is_self ? "button" : "div");
|
const banner = document.createElement("div");
|
||||||
banner.className = "profile-banner";
|
banner.className = "profile-banner";
|
||||||
if (profile.is_self) {
|
|
||||||
banner.type = "button";
|
|
||||||
banner.classList.add("profile-banner-button");
|
|
||||||
banner.title = "Choose banner image";
|
|
||||||
banner.addEventListener("click", () => {
|
|
||||||
Glow.profileOpenMediaPicker("banner");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (profile.profile_banner_color) {
|
if (profile.profile_banner_color) {
|
||||||
banner.style.background =
|
banner.style.background =
|
||||||
`linear-gradient(135deg, ${profile.profile_banner_color}, transparent 72%), ${profile.profile_banner_color}`;
|
`linear-gradient(135deg, ${profile.profile_banner_color}, transparent 72%), ${profile.profile_banner_color}`;
|
||||||
@ -249,34 +111,18 @@ Glow.renderProfileView = (editing = false) => {
|
|||||||
banner.style.backgroundPosition = "center";
|
banner.style.backgroundPosition = "center";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (profile.is_self) {
|
|
||||||
const bannerHint = document.createElement("span");
|
|
||||||
bannerHint.className = "profile-banner-hint";
|
|
||||||
bannerHint.textContent = "Change banner";
|
|
||||||
banner.append(bannerHint);
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = document.createElement("div");
|
const content = document.createElement("div");
|
||||||
content.className = "profile-content";
|
content.className = "profile-content";
|
||||||
|
|
||||||
const header = document.createElement("div");
|
const header = document.createElement("div");
|
||||||
header.className = "profile-header";
|
header.className = "profile-header";
|
||||||
|
|
||||||
const avatar = document.createElement(profile.is_self ? "button" : "div");
|
const avatar = document.createElement("div");
|
||||||
avatar.className = "profile-avatar";
|
avatar.className = "profile-avatar";
|
||||||
avatar.textContent = (profile.display_name || profile.username || "?")
|
avatar.textContent = (profile.display_name || profile.username || "?")
|
||||||
.slice(0, 1)
|
.slice(0, 1)
|
||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
|
|
||||||
if (profile.is_self) {
|
|
||||||
avatar.type = "button";
|
|
||||||
avatar.classList.add("profile-avatar-button");
|
|
||||||
avatar.title = "Choose avatar image";
|
|
||||||
avatar.addEventListener("click", () => {
|
|
||||||
Glow.profileOpenMediaPicker("avatar");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions = document.createElement("div");
|
const actions = document.createElement("div");
|
||||||
actions.className = "profile-actions";
|
actions.className = "profile-actions";
|
||||||
if (profile.is_self) {
|
if (profile.is_self) {
|
||||||
@ -319,22 +165,18 @@ Glow.renderProfileView = (editing = false) => {
|
|||||||
badges.className = "profile-badges";
|
badges.className = "profile-badges";
|
||||||
|
|
||||||
const roleBadge = document.createElement("span");
|
const roleBadge = document.createElement("span");
|
||||||
roleBadge.className = `profile-badge role role-${String(profile.role || "user").toLowerCase()}`;
|
roleBadge.className = "profile-badge";
|
||||||
roleBadge.textContent = profile.role;
|
roleBadge.textContent = profile.role;
|
||||||
|
|
||||||
const statusBadge = document.createElement("span");
|
const statusBadge = document.createElement("span");
|
||||||
statusBadge.className = `profile-badge status status-${String(profile.status || "active").toLowerCase()}`;
|
statusBadge.className = "profile-badge";
|
||||||
statusBadge.textContent = profile.status;
|
statusBadge.textContent = profile.status;
|
||||||
|
|
||||||
badges.append(roleBadge, statusBadge);
|
const presenceBadge = document.createElement("span");
|
||||||
|
presenceBadge.className = "profile-badge";
|
||||||
|
presenceBadge.textContent = profile.presence_mode || "online";
|
||||||
|
|
||||||
if (Glow.profileCanShowPresenceBadge(profile)) {
|
badges.append(roleBadge, statusBadge, presenceBadge);
|
||||||
const presenceBadge = document.createElement("span");
|
|
||||||
presenceBadge.className =
|
|
||||||
`profile-badge presence presence-${String(profile.presence_mode || "online").toLowerCase()}`;
|
|
||||||
presenceBadge.textContent = profile.presence_mode || "online";
|
|
||||||
badges.append(presenceBadge);
|
|
||||||
}
|
|
||||||
|
|
||||||
const grid = document.createElement("div");
|
const grid = document.createElement("div");
|
||||||
grid.className = "profile-grid";
|
grid.className = "profile-grid";
|
||||||
@ -356,42 +198,30 @@ Glow.renderProfileView = (editing = false) => {
|
|||||||
metaList.className = "profile-meta-list";
|
metaList.className = "profile-meta-list";
|
||||||
|
|
||||||
const addMeta = (label, value) => {
|
const addMeta = (label, value) => {
|
||||||
if (!value) return;
|
|
||||||
const dt = document.createElement("dt");
|
const dt = document.createElement("dt");
|
||||||
dt.textContent = label;
|
dt.textContent = label;
|
||||||
const dd = document.createElement("dd");
|
const dd = document.createElement("dd");
|
||||||
dd.textContent = value;
|
dd.textContent = value || "—";
|
||||||
metaList.append(dt, dd);
|
metaList.append(dt, dd);
|
||||||
};
|
};
|
||||||
|
|
||||||
addMeta("Joined", profile.created_at
|
addMeta("Display name", profile.display_name || profile.username);
|
||||||
? new Date(profile.created_at).toLocaleDateString()
|
addMeta("Status message", profile.status_message || "");
|
||||||
: "");
|
addMeta("Presence mode", profile.presence_mode || "online");
|
||||||
|
addMeta("Website", profile.website || "");
|
||||||
addMeta("Location", profile.location || "");
|
addMeta("Location", profile.location || "");
|
||||||
|
addMeta(
|
||||||
|
"Joined",
|
||||||
|
profile.created_at ? new Date(profile.created_at).toLocaleDateString() : ""
|
||||||
|
);
|
||||||
|
addMeta(
|
||||||
|
"Last active",
|
||||||
|
profile.last_active_at ? new Date(profile.last_active_at).toLocaleString() : ""
|
||||||
|
);
|
||||||
|
addMeta("Bio mode", profile.bio_mode || "txt");
|
||||||
|
addMeta("Name color", profile.name_color || "role default");
|
||||||
|
|
||||||
if (profile.last_active_at) {
|
metaSection.append(metaTitle, metaList);
|
||||||
addMeta("Last active", new Date(profile.last_active_at).toLocaleString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const websiteBadge = Glow.profileBuildWebsiteBadge(profile.website || "");
|
|
||||||
if (websiteBadge) {
|
|
||||||
const dt = document.createElement("dt");
|
|
||||||
dt.textContent = "Website";
|
|
||||||
const dd = document.createElement("dd");
|
|
||||||
dd.append(websiteBadge);
|
|
||||||
metaList.append(dt, dd);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!metaList.children.length) {
|
|
||||||
const empty = document.createElement("div");
|
|
||||||
empty.className = "profile-empty";
|
|
||||||
empty.textContent = "No public profile details yet.";
|
|
||||||
metaSection.append(metaTitle, empty);
|
|
||||||
} else {
|
|
||||||
metaSection.append(metaTitle, metaList);
|
|
||||||
}
|
|
||||||
|
|
||||||
grid.append(bioSection, metaSection);
|
grid.append(bioSection, metaSection);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -418,40 +248,6 @@ Glow.buildProfileEditor = profile => {
|
|||||||
const editor = document.createElement("div");
|
const editor = document.createElement("div");
|
||||||
editor.className = "profile-editor";
|
editor.className = "profile-editor";
|
||||||
|
|
||||||
const uploadInfo = document.createElement("div");
|
|
||||||
uploadInfo.className = "profile-upload-section";
|
|
||||||
|
|
||||||
const uploadTitle = document.createElement("div");
|
|
||||||
uploadTitle.className = "profile-upload-title";
|
|
||||||
uploadTitle.textContent = "Profile images";
|
|
||||||
|
|
||||||
const uploadText = document.createElement("p");
|
|
||||||
uploadText.className = "header-subtitle";
|
|
||||||
uploadText.textContent =
|
|
||||||
"Choose new avatar or banner images. A preview/crop step opens before the file is staged.";
|
|
||||||
|
|
||||||
const uploadActions = document.createElement("div");
|
|
||||||
uploadActions.className = "settings-actions";
|
|
||||||
|
|
||||||
const avatarButton = document.createElement("button");
|
|
||||||
avatarButton.className = "button secondary";
|
|
||||||
avatarButton.type = "button";
|
|
||||||
avatarButton.textContent = "Choose avatar";
|
|
||||||
avatarButton.addEventListener("click", () => {
|
|
||||||
Glow.profileOpenMediaPicker("avatar");
|
|
||||||
});
|
|
||||||
|
|
||||||
const bannerButton = document.createElement("button");
|
|
||||||
bannerButton.className = "button secondary";
|
|
||||||
bannerButton.type = "button";
|
|
||||||
bannerButton.textContent = "Choose banner";
|
|
||||||
bannerButton.addEventListener("click", () => {
|
|
||||||
Glow.profileOpenMediaPicker("banner");
|
|
||||||
});
|
|
||||||
|
|
||||||
uploadActions.append(avatarButton, bannerButton);
|
|
||||||
uploadInfo.append(uploadTitle, uploadText, uploadActions);
|
|
||||||
|
|
||||||
const displayName = document.createElement("input");
|
const displayName = document.createElement("input");
|
||||||
displayName.className = "field";
|
displayName.className = "field";
|
||||||
displayName.type = "text";
|
displayName.type = "text";
|
||||||
@ -480,6 +276,11 @@ Glow.buildProfileEditor = profile => {
|
|||||||
location.placeholder = "Location";
|
location.placeholder = "Location";
|
||||||
location.value = profile.location || "";
|
location.value = profile.location || "";
|
||||||
|
|
||||||
|
const bannerInput = document.createElement("input");
|
||||||
|
bannerInput.className = "field";
|
||||||
|
bannerInput.type = "file";
|
||||||
|
bannerInput.accept = ".png,.jpg,.jpeg,.gif,.webp";
|
||||||
|
|
||||||
const modeRow = document.createElement("div");
|
const modeRow = document.createElement("div");
|
||||||
modeRow.className = "profile-mode-row";
|
modeRow.className = "profile-mode-row";
|
||||||
let selectedMode = profile.bio_mode || "txt";
|
let selectedMode = profile.bio_mode || "txt";
|
||||||
@ -515,13 +316,13 @@ Glow.buildProfileEditor = profile => {
|
|||||||
const save = document.createElement("button");
|
const save = document.createElement("button");
|
||||||
save.className = "button";
|
save.className = "button";
|
||||||
save.type = "button";
|
save.type = "button";
|
||||||
save.textContent = "Save profile changes";
|
save.textContent = "Save profile";
|
||||||
save.addEventListener("click", async () => {
|
save.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
let bannerAttachmentId = profile.banner_attachment_id || null;
|
let bannerAttachmentId = profile.banner_attachment_id || null;
|
||||||
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
|
const file = bannerInput.files?.[0];
|
||||||
if (bannerFile) {
|
if (file) {
|
||||||
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
|
const uploaded = await Glow.uploadBannerForProfile(file);
|
||||||
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -535,9 +336,6 @@ Glow.buildProfileEditor = profile => {
|
|||||||
banner_attachment_id: bannerAttachmentId
|
banner_attachment_id: bannerAttachmentId
|
||||||
});
|
});
|
||||||
|
|
||||||
Glow.state.profileMediaDraft.avatarFile = null;
|
|
||||||
Glow.state.profileMediaDraft.bannerFile = null;
|
|
||||||
|
|
||||||
Glow.state.activeProfile = {
|
Glow.state.activeProfile = {
|
||||||
...data.profile,
|
...data.profile,
|
||||||
name_color: Glow.state.settings?.name_color || profile.name_color || null,
|
name_color: Glow.state.settings?.name_color || profile.name_color || null,
|
||||||
@ -551,16 +349,16 @@ Glow.buildProfileEditor = profile => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const apply = document.createElement("button");
|
const preview = document.createElement("button");
|
||||||
apply.className = "button secondary";
|
preview.className = "button secondary";
|
||||||
apply.type = "button";
|
preview.type = "button";
|
||||||
apply.textContent = "Apply changes";
|
preview.textContent = "Preview";
|
||||||
apply.addEventListener("click", async () => {
|
preview.addEventListener("click", async () => {
|
||||||
try {
|
try {
|
||||||
let bannerAttachmentId = profile.banner_attachment_id || null;
|
let bannerAttachmentId = profile.banner_attachment_id || null;
|
||||||
const bannerFile = Glow.state.profileMediaDraft.bannerFile;
|
const file = bannerInput.files?.[0];
|
||||||
if (bannerFile) {
|
if (file) {
|
||||||
const uploaded = await Glow.uploadBannerForProfile(bannerFile);
|
const uploaded = await Glow.uploadBannerForProfile(file);
|
||||||
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
bannerAttachmentId = uploaded?.id || bannerAttachmentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -582,17 +380,17 @@ Glow.buildProfileEditor = profile => {
|
|||||||
};
|
};
|
||||||
Glow.renderProfileView(false);
|
Glow.renderProfileView(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Glow.showToast(error.message || "Could not apply profile changes.", "error");
|
Glow.showToast(error.message || "Could not preview profile.", "error");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
actions.append(save, apply);
|
actions.append(save, preview);
|
||||||
editor.append(
|
editor.append(
|
||||||
uploadInfo,
|
|
||||||
displayName,
|
displayName,
|
||||||
statusMessage,
|
statusMessage,
|
||||||
website,
|
website,
|
||||||
location,
|
location,
|
||||||
|
bannerInput,
|
||||||
modeRow,
|
modeRow,
|
||||||
help,
|
help,
|
||||||
body,
|
body,
|
||||||
@ -601,33 +399,3 @@ Glow.buildProfileEditor = profile => {
|
|||||||
section.append(title, editor);
|
section.append(title, editor);
|
||||||
return section;
|
return section;
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.els.avatarUploadInput?.addEventListener("change", () => {
|
|
||||||
const file = Glow.els.avatarUploadInput.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
Glow.profileOpenCropDialog("avatar", file);
|
|
||||||
Glow.els.avatarUploadInput.value = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.bannerUploadInput?.addEventListener("change", () => {
|
|
||||||
const file = Glow.els.bannerUploadInput.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
Glow.profileOpenCropDialog("banner", file);
|
|
||||||
Glow.els.bannerUploadInput.value = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.imageCropCancel?.addEventListener("click", () => {
|
|
||||||
Glow.profileResetCropState();
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.imageCropApply?.addEventListener("click", () => {
|
|
||||||
Glow.profileApplyCropSelection();
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.imageCropZoom?.addEventListener("input", () => {
|
|
||||||
Glow.state.imageCrop.zoom = Number(Glow.els.imageCropZoom.value || "1") || 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
Glow.els.imageCropAuto?.addEventListener("change", () => {
|
|
||||||
Glow.state.imageCrop.autoCrop = Boolean(Glow.els.imageCropAuto.checked);
|
|
||||||
});
|
|
||||||
@ -5,13 +5,8 @@ window.Glow = window.Glow || {};
|
|||||||
|
|
||||||
Glow.reactions = Glow.reactions || {
|
Glow.reactions = Glow.reactions || {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
emojis: [
|
emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"],
|
||||||
"👍", "👎", "❤️", "🔥", "😂", "😄", "🙂", "😭",
|
activeMessage: null
|
||||||
"👏", "🙌", "🎉", "✨", "💯", "✅", "👀", "🤔",
|
|
||||||
"🙏", "💡", "⭐", "🚀", "😮", "😢", "😡", "👋"
|
|
||||||
],
|
|
||||||
activeMessage: null,
|
|
||||||
selectedIndex: 0
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.submitCustomReaction = async () => {
|
Glow.submitCustomReaction = async () => {
|
||||||
@ -46,47 +41,8 @@ Glow.submitCustomReaction = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.highlightReactionSelection = () => {
|
|
||||||
const grid = Glow.els.reactionPopupGrid;
|
|
||||||
if (!grid) return;
|
|
||||||
|
|
||||||
const buttons = [...grid.querySelectorAll(".reaction-popup-button")];
|
|
||||||
buttons.forEach((button, index) => {
|
|
||||||
button.classList.toggle("active", index === Glow.reactions.selectedIndex);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.activateReactionAtIndex = async index => {
|
|
||||||
const message = Glow.reactions.activeMessage;
|
|
||||||
const emoji = Glow.reactions.emojis[index];
|
|
||||||
if (!message || !emoji) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
|
||||||
|
|
||||||
if (existing?.reacted) {
|
|
||||||
await Glow.apiRemoveReaction(message.id, emoji);
|
|
||||||
} else {
|
|
||||||
await Glow.apiAddReaction(message.id, emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await Glow.apiGetMessageReactions(message.id);
|
|
||||||
message.reactions = data.reactions || [];
|
|
||||||
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
|
||||||
Glow.renderReactionPopupUsers(message.reactions);
|
|
||||||
Glow.highlightReactionSelection();
|
|
||||||
|
|
||||||
if (Glow.state.activeView === "feed") {
|
|
||||||
Glow.loadFeed?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
Glow.showToast(error.message || "Could not update reaction.", "error");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.openReactionPopup = async message => {
|
Glow.openReactionPopup = async message => {
|
||||||
Glow.reactions.activeMessage = message;
|
Glow.reactions.activeMessage = message;
|
||||||
Glow.reactions.selectedIndex = 0;
|
|
||||||
|
|
||||||
const grid = Glow.els.reactionPopupGrid;
|
const grid = Glow.els.reactionPopupGrid;
|
||||||
const list = Glow.els.reactionPopupList;
|
const list = Glow.els.reactionPopupList;
|
||||||
@ -97,21 +53,38 @@ Glow.openReactionPopup = async message => {
|
|||||||
grid.replaceChildren();
|
grid.replaceChildren();
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
|
|
||||||
Glow.reactions.emojis.forEach((emoji, index) => {
|
for (const emoji of Glow.reactions.emojis) {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.className = "reaction-popup-button";
|
button.className = "reaction-popup-button";
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.textContent = emoji;
|
button.textContent = emoji;
|
||||||
button.setAttribute("aria-label", `React with ${emoji}`);
|
|
||||||
|
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
Glow.reactions.selectedIndex = index;
|
try {
|
||||||
Glow.highlightReactionSelection();
|
const existing = (message.reactions || []).find(item => item.emoji === emoji);
|
||||||
await Glow.activateReactionAtIndex(index);
|
|
||||||
|
if (existing?.reacted) {
|
||||||
|
await Glow.apiRemoveReaction(message.id, emoji);
|
||||||
|
} else {
|
||||||
|
await Glow.apiAddReaction(message.id, emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await Glow.apiGetMessageReactions(message.id);
|
||||||
|
message.reactions = data.reactions || [];
|
||||||
|
Glow.updateMessageReactionsDom?.(message.id, message.reactions);
|
||||||
|
|
||||||
|
Glow.renderReactionPopupUsers(message.reactions);
|
||||||
|
|
||||||
|
if (Glow.state.activeView === "feed") {
|
||||||
|
Glow.loadFeed?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Glow.showToast(error.message || "Could not update reaction.", "error");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.append(button);
|
grid.append(button);
|
||||||
});
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await Glow.apiGetMessageReactions(message.id);
|
const data = await Glow.apiGetMessageReactions(message.id);
|
||||||
@ -126,26 +99,12 @@ Glow.openReactionPopup = async message => {
|
|||||||
|
|
||||||
Glow.renderReactionPopupUsers(message.reactions || []);
|
Glow.renderReactionPopupUsers(message.reactions || []);
|
||||||
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
|
Glow.els.reactionPopupOverlay?.classList.remove("hidden");
|
||||||
Glow.highlightReactionSelection();
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const first = grid.querySelector(".reaction-popup-button");
|
Glow.els.reactionCustomInput?.focus();
|
||||||
first?.focus();
|
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
Glow.moveReactionSelection = delta => {
|
|
||||||
const total = Glow.reactions.emojis.length;
|
|
||||||
if (!total) return;
|
|
||||||
Glow.reactions.selectedIndex =
|
|
||||||
(Glow.reactions.selectedIndex + delta + total) % total;
|
|
||||||
Glow.highlightReactionSelection();
|
|
||||||
|
|
||||||
const grid = Glow.els.reactionPopupGrid;
|
|
||||||
const selected = grid?.querySelectorAll(".reaction-popup-button")[Glow.reactions.selectedIndex];
|
|
||||||
selected?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
||||||
};
|
|
||||||
|
|
||||||
Glow.renderReactionPopupUsers = reactions => {
|
Glow.renderReactionPopupUsers = reactions => {
|
||||||
const list = Glow.els.reactionPopupList;
|
const list = Glow.els.reactionPopupList;
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
@ -169,12 +128,19 @@ Glow.renderReactionPopupUsers = reactions => {
|
|||||||
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
|
title.innerHTML = `<strong>${reaction.emoji} ${reaction.count}</strong>`;
|
||||||
section.append(title);
|
section.append(title);
|
||||||
|
|
||||||
const names = document.createElement("div");
|
for (const user of reaction.users || []) {
|
||||||
names.className = "reaction-popup-user";
|
const row = document.createElement("div");
|
||||||
names.textContent = (reaction.users || [])
|
row.className = "reaction-popup-user";
|
||||||
.map(user => user.username)
|
|
||||||
.join(", ");
|
const left = document.createElement("span");
|
||||||
section.append(names);
|
left.textContent = user.username;
|
||||||
|
|
||||||
|
const right = document.createElement("span");
|
||||||
|
right.textContent = new Date(user.created_at).toLocaleString();
|
||||||
|
|
||||||
|
row.append(left, right);
|
||||||
|
section.append(row);
|
||||||
|
}
|
||||||
|
|
||||||
list.append(section);
|
list.append(section);
|
||||||
}
|
}
|
||||||
@ -182,7 +148,6 @@ Glow.renderReactionPopupUsers = reactions => {
|
|||||||
|
|
||||||
Glow.closeReactionPopup = () => {
|
Glow.closeReactionPopup = () => {
|
||||||
Glow.reactions.activeMessage = null;
|
Glow.reactions.activeMessage = null;
|
||||||
Glow.reactions.selectedIndex = 0;
|
|
||||||
if (Glow.els.reactionCustomInput) {
|
if (Glow.els.reactionCustomInput) {
|
||||||
Glow.els.reactionCustomInput.value = "";
|
Glow.els.reactionCustomInput.value = "";
|
||||||
}
|
}
|
||||||
@ -198,49 +163,4 @@ Glow.els.reactionCustomInput?.addEventListener("keydown", event => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
Glow.submitCustomReaction?.();
|
Glow.submitCustomReaction?.();
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.closeReactionPopup?.();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", async event => {
|
|
||||||
if (Glow.els.reactionPopupOverlay?.classList.contains("hidden")) return;
|
|
||||||
|
|
||||||
if (event.key === "ArrowRight") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.moveReactionSelection(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowLeft") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.moveReactionSelection(-1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowDown") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.moveReactionSelection(4);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "ArrowUp") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.moveReactionSelection(-4);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Enter") {
|
|
||||||
const active = document.activeElement;
|
|
||||||
if (active === Glow.els.reactionCustomInput) return;
|
|
||||||
event.preventDefault();
|
|
||||||
await Glow.activateReactionAtIndex(Glow.reactions.selectedIndex);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
event.preventDefault();
|
|
||||||
Glow.closeReactionPopup?.();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@ -34,17 +34,15 @@ Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => {
|
|||||||
Glow.resetMediaModal();
|
Glow.resetMediaModal();
|
||||||
|
|
||||||
const mime = String(mimeType || "").toLowerCase();
|
const mime = String(mimeType || "").toLowerCase();
|
||||||
const compactName = String(name || "Media").trim() || "Media";
|
|
||||||
|
|
||||||
Glow.els.mediaTitle.textContent = compactName;
|
Glow.els.mediaTitle.textContent = name;
|
||||||
Glow.els.mediaTitle.title = compactName;
|
|
||||||
Glow.els.mediaDownloadLink.href = url;
|
Glow.els.mediaDownloadLink.href = url;
|
||||||
Glow.els.mediaDownloadLink.download = compactName;
|
Glow.els.mediaDownloadLink.download = name;
|
||||||
Glow.els.mediaFallbackLink.href = url;
|
Glow.els.mediaFallbackLink.href = url;
|
||||||
|
|
||||||
if (mime.startsWith("image/")) {
|
if (mime.startsWith("image/")) {
|
||||||
Glow.els.mediaImage.src = url;
|
Glow.els.mediaImage.src = url;
|
||||||
Glow.els.mediaImage.alt = compactName;
|
Glow.els.mediaImage.alt = name;
|
||||||
Glow.els.mediaImage.classList.remove("hidden");
|
Glow.els.mediaImage.classList.remove("hidden");
|
||||||
} else if (mime.startsWith("audio/")) {
|
} else if (mime.startsWith("audio/")) {
|
||||||
Glow.els.mediaAudio.src = url;
|
Glow.els.mediaAudio.src = url;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
737
static/style.css
737
static/style.css
File diff suppressed because it is too large
Load Diff
@ -1,17 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta
|
|
||||||
name="viewport"
|
|
||||||
content="width=device-width,initial-scale=1,viewport-fit=cover"
|
|
||||||
>
|
|
||||||
<meta name="theme-color" content="#0f1012">
|
|
||||||
<meta name="color-scheme" content="dark">
|
|
||||||
<meta name="referrer" content="same-origin">
|
|
||||||
<title></title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
403
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -25,115 +25,88 @@
|
|||||||
class="header-dot"
|
class="header-dot"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
></span>
|
></span>
|
||||||
|
|
||||||
<div class="header-room-copy">
|
<div class="header-room-copy">
|
||||||
<div id="headerTitle" class="header-title">Feed</div>
|
<div id="headerTitle" class="header-title">Feed</div>
|
||||||
<div id="headerSubtitle" class="header-subtitle">Recent activity</div>
|
<div id="headerSubtitle" class="header-subtitle">Recent activity</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-navigation">
|
<div class="header-navigation">
|
||||||
<div class="header-nav-group">
|
<button
|
||||||
<button
|
id="feedButton"
|
||||||
id="feedButton"
|
class="header-action feed-button active"
|
||||||
class="header-action feed-button active"
|
type="button"
|
||||||
type="button"
|
aria-label="Open activity feed"
|
||||||
aria-label="Open activity feed"
|
aria-pressed="true"
|
||||||
aria-pressed="true"
|
>
|
||||||
>
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
<button
|
||||||
|
id="pagesButton"
|
||||||
<button
|
class="header-action pages-button"
|
||||||
id="pagesButton"
|
type="button"
|
||||||
class="header-action pages-button"
|
aria-label="Open team pages"
|
||||||
type="button"
|
aria-pressed="false"
|
||||||
aria-label="Open team pages"
|
>
|
||||||
aria-pressed="false"
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||||
>
|
<g fill="none">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
<path d="M3.5 2.5h17v19h-17z"/>
|
||||||
<g fill="none">
|
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
|
||||||
<path d="M3.5 2.5h17v19h-17z"/>
|
</g>
|
||||||
<path stroke="currentColor" stroke-linecap="square" stroke-width="2" d="M3.5 2.5h17v19h-17zM8 8h8m-8 4h8m-8 4h5"/>
|
</svg>
|
||||||
</g>
|
</button>
|
||||||
</svg>
|
<button
|
||||||
</button>
|
id="dmsButton"
|
||||||
|
class="header-action"
|
||||||
<button
|
type="button"
|
||||||
id="dmsButton"
|
aria-label="Open direct messages"
|
||||||
class="header-action"
|
aria-pressed="false"
|
||||||
type="button"
|
title="Direct messages"
|
||||||
aria-label="Open direct messages"
|
>
|
||||||
aria-pressed="false"
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||||
title="Direct messages"
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
</button>
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
<button
|
||||||
</svg>
|
id="backButton"
|
||||||
</button>
|
class="header-action hidden"
|
||||||
|
type="button"
|
||||||
<button
|
aria-label="Go back"
|
||||||
id="backButton"
|
>
|
||||||
class="header-action hidden"
|
Back
|
||||||
type="button"
|
</button>
|
||||||
aria-label="Go back"
|
<select
|
||||||
>
|
id="teamSelect"
|
||||||
Back
|
class="room-select team-select"
|
||||||
</button>
|
aria-label="Select team"
|
||||||
</div>
|
disabled
|
||||||
|
>
|
||||||
<div class="header-context-group">
|
<option value="">Loading teams…</option>
|
||||||
<select
|
</select>
|
||||||
id="teamSelect"
|
<select
|
||||||
class="room-select team-select"
|
id="roomSelect"
|
||||||
aria-label="Select team"
|
class="room-select channel-select"
|
||||||
disabled
|
aria-label="Select channel"
|
||||||
>
|
disabled
|
||||||
<option value="">Loading teams...</option>
|
>
|
||||||
</select>
|
<option value="">Loading channels…</option>
|
||||||
|
</select>
|
||||||
<select
|
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
|
||||||
id="roomSelect"
|
<button
|
||||||
class="room-select channel-select"
|
id="settingsButton"
|
||||||
aria-label="Select channel"
|
class="header-action"
|
||||||
disabled
|
type="button"
|
||||||
>
|
aria-label="Open settings and profile menu"
|
||||||
<option value="">Loading channels...</option>
|
title="Settings and profile"
|
||||||
</select>
|
>
|
||||||
</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
||||||
|
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
|
||||||
<div class="header-utility-group">
|
</svg>
|
||||||
<div id="presenceLabel" class="presence-label hidden" aria-live="polite"></div>
|
</button>
|
||||||
|
|
||||||
<button
|
|
||||||
id="settingsButton"
|
|
||||||
class="header-action"
|
|
||||||
type="button"
|
|
||||||
aria-label="Open settings and profile menu"
|
|
||||||
title="Settings and profile"
|
|
||||||
>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24">
|
|
||||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3l2.3.8l2.4-.5l1.3 2.1l2.2 1l-.3 2.4l1.1 2.1l-1.6 1.8l-.3 2.4l-2.4.5L14 21l-2-.8l-2 .8l-1.4-2.1l-2.4-.5l-.3-2.4L4.3 14l1.1-2.1l-.3-2.4l2.2-1l1.3-2.1l2.4.5zM12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7z"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
id="callStatusChip"
|
|
||||||
class="header-action call-status-chip"
|
|
||||||
type="button"
|
|
||||||
aria-label="Open call panel"
|
|
||||||
title="Voice call"
|
|
||||||
>
|
|
||||||
Call
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-account-wrap">
|
<div class="header-account-wrap">
|
||||||
<div id="headerAccount" class="header-account">not signed in</div>
|
<div id="headerAccount" class="header-account">not signed in</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="logoutButton"
|
id="logoutButton"
|
||||||
class="header-action hidden"
|
class="header-action hidden"
|
||||||
@ -148,7 +121,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="feedBox"
|
id="feedBox"
|
||||||
class="feed-box"
|
class="feed-box"
|
||||||
@ -157,10 +129,9 @@
|
|||||||
aria-label="Activity feed"
|
aria-label="Activity feed"
|
||||||
>
|
>
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
<div class="empty-card">Loading activity...</div>
|
<div class="empty-card">Loading activity…</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="dmBox"
|
id="dmBox"
|
||||||
class="dm-box hidden"
|
class="dm-box hidden"
|
||||||
@ -172,7 +143,6 @@
|
|||||||
<div class="empty-card">Sign in to view direct messages.</div>
|
<div class="empty-card">Sign in to view direct messages.</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="chatBox"
|
id="chatBox"
|
||||||
class="chat-box hidden"
|
class="chat-box hidden"
|
||||||
@ -180,7 +150,6 @@
|
|||||||
aria-busy="true"
|
aria-busy="true"
|
||||||
aria-label="Channel messages"
|
aria-label="Channel messages"
|
||||||
></main>
|
></main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="threadBox"
|
id="threadBox"
|
||||||
class="thread-box hidden"
|
class="thread-box hidden"
|
||||||
@ -188,7 +157,6 @@
|
|||||||
aria-busy="false"
|
aria-busy="false"
|
||||||
aria-label="Message thread"
|
aria-label="Message thread"
|
||||||
></main>
|
></main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="pagesBox"
|
id="pagesBox"
|
||||||
class="pages-box hidden"
|
class="pages-box hidden"
|
||||||
@ -196,7 +164,6 @@
|
|||||||
aria-busy="false"
|
aria-busy="false"
|
||||||
aria-label="Team pages"
|
aria-label="Team pages"
|
||||||
></main>
|
></main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="pageDetailBox"
|
id="pageDetailBox"
|
||||||
class="page-detail-box hidden"
|
class="page-detail-box hidden"
|
||||||
@ -204,7 +171,6 @@
|
|||||||
aria-busy="false"
|
aria-busy="false"
|
||||||
aria-label="Page details"
|
aria-label="Page details"
|
||||||
></main>
|
></main>
|
||||||
|
|
||||||
<main
|
<main
|
||||||
id="profileBox"
|
id="profileBox"
|
||||||
class="profile-box hidden"
|
class="profile-box hidden"
|
||||||
@ -212,136 +178,96 @@
|
|||||||
aria-busy="false"
|
aria-busy="false"
|
||||||
aria-label="Profile"
|
aria-label="Profile"
|
||||||
></main>
|
></main>
|
||||||
|
|
||||||
<div id="newMessagesBar" class="new-messages-bar hidden">
|
<div id="newMessagesBar" class="new-messages-bar hidden">
|
||||||
<button id="jumpToLatestButton" class="small-button" type="button">
|
<button id="jumpToLatestButton" class="small-button" type="button">
|
||||||
New messages · Jump to latest
|
New messages · Jump to latest
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="typingBar" class="typing-bar hidden" aria-live="polite"></div>
|
<div id="typingBar" class="typing-bar hidden" aria-live="polite"></div>
|
||||||
|
|
||||||
<div id="composerWrap" class="composer-wrap hidden">
|
<div id="composerWrap" class="composer-wrap hidden">
|
||||||
<form id="chatForm" class="composer">
|
<form id="chatForm" class="composer">
|
||||||
<div class="composer-stack">
|
<label class="sr-only" for="messageInput">Message</label>
|
||||||
<div class="composer-shell">
|
<div class="composer-field-wrap">
|
||||||
<label class="sr-only" for="messageInput">Message</label>
|
<textarea
|
||||||
|
id="messageInput"
|
||||||
<textarea
|
class="composer-input"
|
||||||
id="messageInput"
|
rows="1"
|
||||||
class="composer-input"
|
maxlength="1000"
|
||||||
rows="1"
|
autocomplete="off"
|
||||||
maxlength="1000"
|
placeholder="sign in to send"
|
||||||
autocomplete="off"
|
aria-label="Message"
|
||||||
placeholder="sign in to send"
|
disabled
|
||||||
aria-label="Message"
|
></textarea>
|
||||||
disabled
|
<div id="emojiAutocomplete" class="emoji-autocomplete hidden"></div>
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<div class="composer-shell-actions">
|
|
||||||
<label
|
|
||||||
id="attachmentButton"
|
|
||||||
class="attachment-button disabled"
|
|
||||||
for="attachmentInput"
|
|
||||||
title="Attach file(s)"
|
|
||||||
aria-label="Attach file(s)"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button id="sendButton" class="send-button" type="submit" disabled>
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="emojiAutocomplete" class="emoji-autocomplete hidden" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<div class="composer-meta">
|
<div class="composer-meta">
|
||||||
<span id="attachmentName" class="attachment-name"></span>
|
<span id="attachmentName" class="attachment-name"></span>
|
||||||
<span id="messageCount" class="message-count">0 / 1000</span>
|
<span id="messageCount" class="message-count">0 / 1000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
id="attachmentButton"
|
||||||
|
class="attachment-button disabled"
|
||||||
|
for="attachmentInput"
|
||||||
|
title="Attach file(s)"
|
||||||
|
aria-label="Attach file(s)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="attachmentInput"
|
id="attachmentInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
|
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.ogg,.m4a,.aac,.flac,.mp4,.webm,.mov,.m4v,.ogv"
|
||||||
multiple
|
multiple
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
|
<button id="sendButton" class="send-button" type="submit" disabled>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="threadComposerWrap" class="composer-wrap hidden">
|
<div id="threadComposerWrap" class="composer-wrap hidden">
|
||||||
<form id="threadForm" class="composer">
|
<form id="threadForm" class="composer">
|
||||||
<div class="composer-stack">
|
<label class="sr-only" for="threadInput">Reply in thread</label>
|
||||||
<div class="composer-shell">
|
<div class="composer-field-wrap">
|
||||||
<label class="sr-only" for="threadInput">Reply in thread</label>
|
<textarea
|
||||||
|
id="threadInput"
|
||||||
<textarea
|
class="composer-input"
|
||||||
id="threadInput"
|
rows="1"
|
||||||
class="composer-input"
|
maxlength="1000"
|
||||||
rows="1"
|
autocomplete="off"
|
||||||
maxlength="1000"
|
placeholder="reply in thread"
|
||||||
autocomplete="off"
|
aria-label="Reply in thread"
|
||||||
placeholder="reply in thread"
|
disabled
|
||||||
aria-label="Reply in thread"
|
></textarea>
|
||||||
disabled
|
|
||||||
></textarea>
|
|
||||||
|
|
||||||
<div class="composer-shell-actions">
|
|
||||||
<label
|
|
||||||
id="threadAttachmentButton"
|
|
||||||
class="attachment-button disabled"
|
|
||||||
for="threadAttachmentInput"
|
|
||||||
title="Attach file(s)"
|
|
||||||
aria-label="Attach file(s)"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<button id="threadSendButton" class="send-button" type="submit" disabled>
|
|
||||||
Reply
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="threadEmojiAutocomplete" class="emoji-autocomplete hidden" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<div class="composer-meta">
|
<div class="composer-meta">
|
||||||
<span id="threadAttachmentName" class="attachment-name"></span>
|
<span id="threadAttachmentName" class="attachment-name"></span>
|
||||||
<span id="threadMessageCount" class="message-count">0 / 1000</span>
|
<span id="threadMessageCount" class="message-count">0 / 1000</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
id="threadAttachmentButton"
|
||||||
|
class="attachment-button disabled"
|
||||||
|
for="threadAttachmentInput"
|
||||||
|
title="Attach file(s)"
|
||||||
|
aria-label="Attach file(s)"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
id="threadAttachmentInput"
|
id="threadAttachmentInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp"
|
accept=".txt,.pdf,.png,.jpg,.jpeg,.gif,.webp,.mp3,.wav,.ogg,.m4a,.aac,.flac,.mp4,.webm,.mov,.m4v,.ogv"
|
||||||
multiple
|
multiple
|
||||||
disabled
|
disabled
|
||||||
>
|
>
|
||||||
|
<button id="threadSendButton" class="send-button" type="submit" disabled>
|
||||||
|
Reply
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
|
||||||
id="avatarUploadInput"
|
|
||||||
class="sr-only"
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
|
||||||
>
|
|
||||||
|
|
||||||
<input
|
|
||||||
id="bannerUploadInput"
|
|
||||||
class="sr-only"
|
|
||||||
type="file"
|
|
||||||
accept=".png,.jpg,.jpeg,.gif,.webp"
|
|
||||||
>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="authOverlay"
|
id="authOverlay"
|
||||||
class="auth-overlay"
|
class="auth-overlay"
|
||||||
@ -351,9 +277,7 @@
|
|||||||
>
|
>
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<p></p>
|
<p></p>
|
||||||
|
|
||||||
<div id="authNotice" class="notice" role="status"></div>
|
<div id="authNotice" class="notice" role="status"></div>
|
||||||
|
|
||||||
<label class="sr-only" for="accessKeyInput">access key</label>
|
<label class="sr-only" for="accessKeyInput">access key</label>
|
||||||
<input
|
<input
|
||||||
id="accessKeyInput"
|
id="accessKeyInput"
|
||||||
@ -363,9 +287,8 @@
|
|||||||
autocapitalize="none"
|
autocapitalize="none"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
maxlength="256"
|
maxlength="256"
|
||||||
placeholder="access key"
|
placeholder="private access key"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label class="sr-only" for="usernameInput">Username</label>
|
<label class="sr-only" for="usernameInput">Username</label>
|
||||||
<input
|
<input
|
||||||
id="usernameInput"
|
id="usernameInput"
|
||||||
@ -377,7 +300,6 @@
|
|||||||
maxlength="30"
|
maxlength="30"
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
>
|
>
|
||||||
|
|
||||||
<label class="sr-only" for="passwordInput">Password</label>
|
<label class="sr-only" for="passwordInput">Password</label>
|
||||||
<input
|
<input
|
||||||
id="passwordInput"
|
id="passwordInput"
|
||||||
@ -387,16 +309,13 @@
|
|||||||
maxlength="256"
|
maxlength="256"
|
||||||
placeholder="password"
|
placeholder="password"
|
||||||
>
|
>
|
||||||
|
|
||||||
<div class="auth-actions">
|
<div class="auth-actions">
|
||||||
<button id="loginButton" class="button" type="button">sign in</button>
|
<button id="loginButton" class="button" type="button">sign in</button>
|
||||||
<button id="registerButton" class="button secondary" type="button">create account</button>
|
<button id="registerButton" class="button secondary" type="button">create account</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="auth-help"></p>
|
<p class="auth-help"></p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="blockedOverlay"
|
id="blockedOverlay"
|
||||||
class="blocked-overlay hidden"
|
class="blocked-overlay hidden"
|
||||||
@ -406,10 +325,9 @@
|
|||||||
>
|
>
|
||||||
<div class="auth-card">
|
<div class="auth-card">
|
||||||
<h1>Access blocked</h1>
|
<h1>Access blocked</h1>
|
||||||
<p id="blockedMessage">Access is unavailable.</p>
|
<p id="blockedMessage">Access to this chat service is unavailable.</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="headerMenuOverlay"
|
id="headerMenuOverlay"
|
||||||
class="header-menu-overlay hidden"
|
class="header-menu-overlay hidden"
|
||||||
@ -422,7 +340,6 @@
|
|||||||
<div id="headerMenuList" class="header-menu-list"></div>
|
<div id="headerMenuList" class="header-menu-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="reactionPopupOverlay"
|
id="reactionPopupOverlay"
|
||||||
class="reaction-popup-overlay hidden"
|
class="reaction-popup-overlay hidden"
|
||||||
@ -431,12 +348,8 @@
|
|||||||
aria-label="Reactions"
|
aria-label="Reactions"
|
||||||
>
|
>
|
||||||
<div class="reaction-popup-card">
|
<div class="reaction-popup-card">
|
||||||
<div class="reaction-popup-header">
|
<h2 class="reaction-popup-title">Reactions</h2>
|
||||||
<h2 class="reaction-popup-title">Reactions</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
|
<div id="reactionPopupGrid" class="reaction-popup-grid"></div>
|
||||||
|
|
||||||
<div class="reaction-popup-custom">
|
<div class="reaction-popup-custom">
|
||||||
<input
|
<input
|
||||||
id="reactionCustomInput"
|
id="reactionCustomInput"
|
||||||
@ -454,15 +367,12 @@
|
|||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="reactionPopupList" class="reaction-popup-list"></div>
|
<div id="reactionPopupList" class="reaction-popup-list"></div>
|
||||||
|
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button id="closeReactionPopupButton" class="button secondary" type="button">Close</button>
|
<button id="closeReactionPopupButton" class="button secondary" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="mediaOverlay"
|
id="mediaOverlay"
|
||||||
class="media-overlay hidden"
|
class="media-overlay hidden"
|
||||||
@ -471,15 +381,14 @@
|
|||||||
aria-label="Media preview"
|
aria-label="Media preview"
|
||||||
>
|
>
|
||||||
<div class="media-card">
|
<div class="media-card">
|
||||||
<div class="media-header minimal">
|
<div class="media-header">
|
||||||
<div id="mediaTitle" class="media-title">Media</div>
|
<div id="mediaTitle" class="media-title">Media</div>
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
|
<a id="mediaDownloadLink" class="button secondary" href="#" download>Download</a>
|
||||||
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
|
<button id="closeMediaButton" class="button secondary" type="button">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="media-body">
|
||||||
<div class="media-body spacious">
|
|
||||||
<img id="mediaImage" class="hidden" alt="">
|
<img id="mediaImage" class="hidden" alt="">
|
||||||
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
|
<audio id="mediaAudio" class="hidden" controls preload="metadata"></audio>
|
||||||
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
|
<video id="mediaVideo" class="hidden" controls preload="metadata"></video>
|
||||||
@ -490,104 +399,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
|
||||||
id="imageCropOverlay"
|
|
||||||
class="dialog-overlay hidden"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
aria-label="Image crop and preview"
|
|
||||||
>
|
|
||||||
<div class="dialog-card crop-dialog-card">
|
|
||||||
<h2>Adjust image</h2>
|
|
||||||
|
|
||||||
<div class="crop-dialog-body">
|
|
||||||
<div id="imageCropPreview" class="crop-preview-area" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<label class="dialog-label" for="imageCropZoom">
|
|
||||||
Zoom
|
|
||||||
<input
|
|
||||||
id="imageCropZoom"
|
|
||||||
class="field"
|
|
||||||
type="range"
|
|
||||||
min="1"
|
|
||||||
max="3"
|
|
||||||
step="0.05"
|
|
||||||
value="1"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="dialog-label crop-toggle">
|
|
||||||
<input id="imageCropAuto" type="checkbox">
|
|
||||||
Auto-crop placeholder
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="dialog-actions">
|
|
||||||
<button id="imageCropCancel" class="button secondary" type="button">Cancel</button>
|
|
||||||
<button id="imageCropApply" class="button" type="button">Apply</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="callOverlay"
|
id="callOverlay"
|
||||||
class="call-overlay hidden"
|
class="call-overlay hidden"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="false"
|
aria-modal="true"
|
||||||
aria-label="Voice call"
|
aria-label="Voice call"
|
||||||
>
|
>
|
||||||
<div class="call-card utility-panel">
|
<div class="call-card">
|
||||||
<div id="callDragHandle" class="call-drag-handle" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<div class="call-title-row">
|
<div class="call-title-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="call-title">call</div>
|
<div class="call-title">Voice call</div>
|
||||||
<div id="callStatus" class="call-status">Idle</div>
|
<div id="callStatus" class="call-status">Idle</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
|
<button id="closeCallPanelButton" class="button secondary" type="button">Hide</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="callPeerLabel" class="call-peer-label">No active call</div>
|
<div id="callPeerLabel" class="call-peer-label">No active call</div>
|
||||||
|
<audio id="remoteAudio" autoplay playsinline></audio>
|
||||||
<div id="callSummaryStats" class="call-summary-stats">
|
|
||||||
<div class="call-stat-row">
|
|
||||||
<span class="call-stat-label">Users</span>
|
|
||||||
<span id="callUserCountStat" class="call-stat-value">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="call-stat-row">
|
|
||||||
<span class="call-stat-label">Elapsed</span>
|
|
||||||
<span id="callElapsedStat" class="call-stat-value">0m</span>
|
|
||||||
</div>
|
|
||||||
<div class="call-stat-row">
|
|
||||||
<span class="call-stat-label">Latency</span>
|
|
||||||
<span id="callLatencyStat" class="call-stat-value"> </span>
|
|
||||||
</div>
|
|
||||||
<div class="call-stat-row">
|
|
||||||
<span class="call-stat-label">Loss</span>
|
|
||||||
<span id="callPacketLossStat" class="call-stat-value"> </span>
|
|
||||||
</div>
|
|
||||||
<div class="call-stat-row">
|
|
||||||
<span class="call-stat-label">Uptime</span>
|
|
||||||
<span id="callUptimeStat" class="call-stat-value"> </span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="callParticipants" class="call-participants"></div>
|
|
||||||
<div id="callPendingInvites" class="call-pending-invites hidden"></div>
|
|
||||||
<div id="remoteAudioContainer" class="remote-audio-container"></div>
|
|
||||||
|
|
||||||
<div class="call-actions">
|
<div class="call-actions">
|
||||||
<button id="startCallButton" class="button" type="button">Start call</button>
|
<button id="startCallButton" class="button" type="button">Start call</button>
|
||||||
<button id="retryCallButton" class="button secondary hidden" type="button">Retry connection</button>
|
<button id="answerCallButton" class="button hidden" type="button">Answer</button>
|
||||||
<button id="addToCallButton" class="button secondary hidden" type="button">Add user</button>
|
|
||||||
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
|
<button id="muteCallButton" class="button secondary hidden" type="button">Mute</button>
|
||||||
<button id="hangupCallButton" class="danger-button hidden" type="button">Leave call</button>
|
<button id="hangupCallButton" class="danger-button hidden" type="button">Hang up</button>
|
||||||
<button id="endCallButton" class="danger-button hidden" type="button">End call</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="adminFab"
|
id="adminFab"
|
||||||
class="admin-fab"
|
class="admin-fab"
|
||||||
@ -597,7 +433,6 @@
|
|||||||
>
|
>
|
||||||
#
|
#
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="adminPanel"
|
id="adminPanel"
|
||||||
class="admin-panel"
|
class="admin-panel"
|
||||||
@ -612,63 +447,21 @@
|
|||||||
<strong>Moderation</strong>
|
<strong>Moderation</strong>
|
||||||
<div class="header-subtitle"></div>
|
<div class="header-subtitle"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="closeAdminButton" class="button secondary" type="button">
|
<button id="closeAdminButton" class="button secondary" type="button">
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="admin-actions">
|
<div class="admin-actions">
|
||||||
<button id="createRoomButton" class="button" type="button">Add chat</button>
|
<button id="createRoomButton" class="button" type="button">Add chat</button>
|
||||||
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
|
<button id="clearMessagesButton" class="danger-button" type="button">Clear selected room</button>
|
||||||
|
<button id="typingToggleButton" class="button secondary" type="button">Typing indicator: on</button>
|
||||||
|
<button id="notificationsToggleButton" class="button secondary" type="button">Notifications: off</button>
|
||||||
|
<button id="themeButton" class="button secondary" type="button" aria-label="Choose accent color">Accent color</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="adminRooms" class="admin-rooms"></div>
|
||||||
<div class="admin-tabs" role="tablist" aria-label="Moderation sections">
|
<div id="adminUsers" class="admin-users"></div>
|
||||||
<button
|
|
||||||
id="adminTabRooms"
|
|
||||||
class="admin-tab active"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="true"
|
|
||||||
>
|
|
||||||
Rooms
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="adminTabUsers"
|
|
||||||
class="admin-tab"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Users
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="adminTabTeams"
|
|
||||||
class="admin-tab"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
aria-selected="false"
|
|
||||||
>
|
|
||||||
Teams/channels
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="admin-tab-panels">
|
|
||||||
<div id="adminRoomsPanel" class="admin-tab-panel">
|
|
||||||
<div id="adminRooms" class="admin-rooms"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="adminUsersPanel" class="admin-tab-panel hidden">
|
|
||||||
<div id="adminUsers" class="admin-users"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="adminTeamsPanel" class="admin-tab-panel hidden">
|
|
||||||
<div id="adminTeamsChannels" class="admin-rooms"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="dialogOverlay"
|
id="dialogOverlay"
|
||||||
class="dialog-overlay hidden"
|
class="dialog-overlay hidden"
|
||||||
@ -682,26 +475,7 @@
|
|||||||
<div id="dialogActions" class="dialog-actions"></div>
|
<div id="dialogActions" class="dialog-actions"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div
|
|
||||||
id="settingsSavePanel"
|
|
||||||
class="settings-save-panel hidden"
|
|
||||||
aria-live="polite"
|
|
||||||
>
|
|
||||||
<div class="settings-save-panel-inner">
|
|
||||||
<div class="settings-save-copy">
|
|
||||||
<strong id="settingsUnsavedIndicator">Unsaved changes</strong>
|
|
||||||
<small>Your previews are local until you save.</small>
|
|
||||||
</div>
|
|
||||||
<div class="settings-save-actions">
|
|
||||||
<button id="settingsCloseButton" class="button secondary" type="button">Close</button>
|
|
||||||
<button id="settingsSaveButton" class="button" type="button">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
|
<div id="statusToast" class="status-toast" role="status" aria-live="polite"></div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
|
<script src="{{ url_for('static', filename='js/00-config.js', v=asset_version) }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/01-api.js', v=asset_version) }}" defer></script>
|
<script src="{{ url_for('static', filename='js/01-api.js', v=asset_version) }}" defer></script>
|
||||||
<script src="{{ url_for('static', filename='js/02-chat.js', v=asset_version) }}" defer></script>
|
<script src="{{ url_for('static', filename='js/02-chat.js', v=asset_version) }}" defer></script>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user