commit a1e3727c3f7a8d1522cf6446b498dd0e605b39be Author: admin Date: Thu Sep 24 22:33:36 2026 +0000 Initial commit diff --git a/.env b/.env new file mode 100644 index 0000000..21df4e2 --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=StrongPass123! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c9717f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=5000 + +COPY requirements.txt ./ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /app/data /app/data/uploads /tmp /run/gunicorn \ + && chown -R 1000:1000 /app /tmp /run/gunicorn + +USER 1000:1000 + +EXPOSE 5000 + +CMD ["gunicorn", \ + "--bind", "0.0.0.0:5000", \ + "--workers", "1", \ + "--threads", "4", \ + "--timeout", "60", \ + "--graceful-timeout", "20", \ + "--keep-alive", "5", \ + "--worker-tmp-dir", "/tmp", \ + "--access-logfile", "-", \ + "--error-logfile", "-", \ + "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..fb80139 --- /dev/null +++ b/app.py @@ -0,0 +1,1990 @@ +# app.py +import base64 +import html +import hmac +import json +import mimetypes +import os +import re +import secrets +import sqlite3 +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Optional + +from flask import Flask, g, jsonify, render_template, request, session +from werkzeug.exceptions import BadRequest, RequestEntityTooLarge +from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +from features import maybe_cleanup_orphaned_attachments +from routes import register_routes + + +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_REQUEST_BYTES = 6 * 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_BYTES = 100 * 1024 * 1024 +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_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 + +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", +} + +USERNAME_RE = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{1,28}[a-z0-9])?$") +ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") + +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") + +app = Flask(__name__) +app.secret_key = SECRET_KEY + +trusted_proxy_hops = int(os.environ.get("TRUSTED_PROXY_HOPS", "0")) +if trusted_proxy_hops > 0: + app.wsgi_app = ProxyFix(app.wsgi_app, x_for=trusted_proxy_hops, x_proto=trusted_proxy_hops, x_host=trusted_proxy_hops) + +app.config.update( + MAX_CONTENT_LENGTH=MAX_REQUEST_BYTES, + SESSION_COOKIE_NAME="chat_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + 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), *[int(path.stat().st_mtime) for path in js_dir.glob("*.js")]] + return str(max(values)) + except OSError: + return "1" + + +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 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_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 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), + } + + +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: + return """ + SELECT + p.id, + p.team_id, + p.channel_id, + 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 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 client_ip() -> str: + return (request.remote_addr or "unknown")[:128] + + +def hash_identifier(value: str) -> str: + return hmac.new(CLIENT_HASH_KEY.encode("utf-8"), value.encode("utf-8"), ctx_hash()).hexdigest() + + +def ctx_hash(): + import hashlib + return hashlib.sha256 + + +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 = time.time() + cutoff = now - 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 - oldest)) + 1) + + db.execute( + """ + INSERT INTO rate_limit_events ( + scope, + subject_hash, + created_at + ) VALUES (?, ?, ?) + """, + (scope, subject_hash, now), + ) + 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 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 user_payload(row: sqlite3.Row) -> 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: sqlite3.Row | dict) -> 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 get_asset_template_values(): + return {"asset_version": get_asset_version(), "csp_nonce": getattr(g, "csp_nonce", "")} + + +@app.before_request +def setup_request_security(): + g.csp_nonce = base64.b64encode(secrets.token_bytes(18)).decode("ascii") + if request_blacklisted(): + if request.path.startswith("/api/"): + return jsonify({"error": "Access to this service has been blocked.", "code": "blacklisted"}), 403 + return render_template("blocked.html", asset_version=get_asset_version()), 403 + + +@app.context_processor +def inject_template_values(): + return get_asset_template_values() + + +@app.after_request +def add_security_headers(response): + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "same-origin" + response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()" + response.headers["Cross-Origin-Opener-Policy"] = "same-origin" + response.headers["Cross-Origin-Resource-Policy"] = "same-origin" + response.headers["Content-Security-Policy"] = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "img-src 'self' data: blob:; " + "media-src 'self' blob:; " + "style-src 'self' 'unsafe-inline'; " + "script-src 'self'; " + "connect-src 'self';" + ) + + if request.path.startswith("/static/"): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + else: + response.headers["Cache-Control"] = "no-store" + return response + + +@app.errorhandler(RequestEntityTooLarge) +def request_too_large(_error): + return jsonify({"error": "Request is too large."}), 413 + + +@app.errorhandler(sqlite3.OperationalError) +def sqlite_error(error): + app.logger.exception("Database error: %s", error) + return jsonify({"error": "A database error occurred."}), 500 + + +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 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, + bio TEXT NOT NULL DEFAULT '', + bio_mode TEXT NOT NULL DEFAULT 'txt', + website TEXT, + location TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + """ + ) + + 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', + 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 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 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 + ) + """ + ) + + 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_settings", "name_color"): + db.execute("ALTER TABLE user_settings ADD COLUMN name_color TEXT") + 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)") + + 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, + bio, + bio_mode, + website, + location, + updated_at + ) + SELECT + id, + username, + '', + 'txt', + '', + '', + ? + FROM users + """, + (now_iso(),), + ) + + db.execute( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + updated_at + ) + SELECT + id, + '#f7dce8', + NULL, + '', + 'dark', + ? + 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 = normalize_username(os.environ.get("ADMIN_USERNAME", "")) + password = os.environ.get("ADMIN_PASSWORD", "") + + if not username or not password: + return + + if validate_username(username) or validate_password(password, username): + print("WARNING: Admin bootstrap credentials failed validation.") + 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, + bio, + bio_mode, + website, + location, + updated_at + ) VALUES (?, ?, '', 'txt', '', '', ?) + """, + (user_id, username, now_iso()), + ) + + db.execute( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + updated_at + ) VALUES (?, '#f7dce8', NULL, '', 'dark', ?) + """, + (user_id, now_iso()), + ) + + db.commit() + + +@app.route("/") +def index(): + return render_template("index.html") + + +@app.route("/api/csrf") +def api_csrf(): + return jsonify({"csrf_token": csrf_token()}) + + +@app.route("/api/me") +def api_me(): + user = current_user() + return jsonify({"authenticated": bool(user), "user": user, "csrf_token": csrf_token()}) + + +@app.route("/api/register", methods=["POST"]) +def register(): + limited = rate_limited("register") + if limited: + return limited + + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + 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 jsonify({"error": username_error}), 400 + if password_error: + return 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 sqlite3.IntegrityError: + return jsonify({"error": "Unable to create account with those details."}), 409 + + 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, + bio, + bio_mode, + website, + location, + updated_at + ) VALUES (?, ?, '', 'txt', '', '', ?) + """, + (user["id"], username, now_iso()), + ) + + db_write( + """ + INSERT OR IGNORE INTO user_settings ( + user_id, + accent_color, + name_color, + custom_css, + theme_mode, + updated_at + ) VALUES (?, '#f7dce8', NULL, '', 'dark', ?) + """, + (user["id"], now_iso()), + ) + + authenticated = login_user(user) + audit("account_registered", user["id"], "user", str(user["id"])) + + return jsonify({"status": "registered", "user": authenticated, "csrf_token": csrf_token()}), 201 + + +@app.route("/api/login", methods=["POST"]) +def login(): + limited = rate_limited("login") + if limited: + return limited + + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + 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 jsonify({"error": "Invalid username or password."}), 401 + + if row["status"] in {"disabled", "blacklisted"}: + return 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 jsonify({"status": "logged_in", "user": authenticated, "csrf_token": csrf_token()}) + + +@app.route("/api/logout", methods=["POST"]) +def logout(): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + session.clear() + return jsonify({"status": "logged_out", "csrf_token": csrf_token()}) + + +def _maybe_cleanup_orphaned_attachments(): + maybe_cleanup_orphaned_attachments( + _runtime_state, + ATTACHMENT_CLEANUP_INTERVAL_SECONDS, + lambda: __import__("features").cleanup_orphaned_attachments( + get_db, + UPLOAD_DIR, + utc_now, + ATTACHMENT_ORPHAN_TTL_HOURS, + app.logger, + ), + app.logger, + ) + + +register_routes( + app, + { + "get_db": get_db, + "db_write": db_write, + "safe_json": safe_json, + "csrf_valid": csrf_valid, + "rate_limited": rate_limited, + "current_user": current_user, + "require_user": require_user, + "require_writable_user": require_writable_user, + "require_moderator": require_moderator, + "require_admin": require_admin, + "now_iso": now_iso, + "utc_now": utc_now, + "parse_iso": parse_iso, + "normalize_username": normalize_username, + "audit": audit, + "hash_identifier": hash_identifier, + "room_by_id": room_by_id, + "get_room_by_slug": get_room_by_slug, + "can_access_room": can_access_room, + "can_write_room": can_write_room, + "can_manage_room": can_manage_room, + "room_payload": room_payload, + "team_slug_from_name": team_slug_from_name, + "page_slug_from_title": page_slug_from_title, + "get_team_by_slug": get_team_by_slug, + "get_channel_by_id": get_channel_by_id, + "get_channel_by_slug": get_channel_by_slug, + "get_channel_by_legacy_room_id": get_channel_by_legacy_room_id, + "team_member_role": team_member_role, + "is_team_member": is_team_member, + "is_team_admin": is_team_admin, + "can_edit_team": can_edit_team, + "can_access_team": can_access_team, + "can_access_channel": can_access_channel, + "can_write_channel": can_write_channel, + "team_payload": team_payload, + "channel_payload": channel_payload, + "user_payload": user_payload, + "MESSAGE_SELECT": MESSAGE_SELECT, + "team_page_select": team_page_select, + "UPLOAD_DIR": UPLOAD_DIR, + "secure_filename": secure_filename, + "mimetypes": mimetypes, + "secrets": secrets, + "os": os, + "timedelta": timedelta, + "html": html, + "attachment_list_for_message": lambda message_id: __import__("features").attachment_list_for_message(get_db, message_id), + "maybe_cleanup_orphaned_attachments": _maybe_cleanup_orphaned_attachments, + "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_OWNED_TEAMS_PER_USER": MAX_OWNED_TEAMS_PER_USER, + "ROOM_VISIBILITIES": ROOM_VISIBILITIES, + "ROLES": ROLES, + "STATUSES": STATUSES, + }, + }, +) + + +init_db() +bootstrap_admin() + +if __name__ == "__main__": + app.run( + host="0.0.0.0", + port=int(os.environ.get("PORT", 5000)), + debug=os.environ.get("FLASK_DEBUG", "0") == "1", + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2e37e3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,61 @@ +services: + monet4: + build: + context: . + dockerfile: Dockerfile + container_name: monet4 + + env_file: + - .env + + environment: + PORT: 5000 + FLASK_DEBUG: 0 + + CHAT_DB_PATH: /app/data/chat.db + CHAT_UPLOAD_DIR: /app/data/uploads + CHAT_SECRET_KEY_PATH: /app/data/flask_secret.key + CHAT_CLIENT_HASH_KEY_PATH: /app/data/client_hash.key + + SESSION_COOKIE_SECURE: 1 + TRUSTED_PROXY_HOPS: 1 + + GUNICORN_CMD_ARGS: "--worker-tmp-dir /tmp" + + FLASK_SECRET_KEY: ${FLASK_SECRET_KEY:-} + CHAT_CLIENT_HASH_KEY: ${CHAT_CLIENT_HASH_KEY:-} + + volumes: + - chat_data:/app/data + + networks: + - privatenet + + restart: unless-stopped + + read_only: true + + tmpfs: + - /tmp:size=64m,mode=1777 + - /run/gunicorn:size=16m,mode=1777 + + security_opt: + - no-new-privileges:true + + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:5000/api/csrf', timeout=5).read()"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + + labels: + caddy: money.backend-3.com + caddy.reverse_proxy: "{{upstreams 5000}}" + +volumes: + chat_data: + +networks: + privatenet: + external: true diff --git a/features.py b/features.py new file mode 100644 index 0000000..5e0470a --- /dev/null +++ b/features.py @@ -0,0 +1,1031 @@ +# features.py +import json +import mimetypes +import re +import secrets +import time +from datetime import timedelta +from pathlib import Path +from typing import Optional + +from flask import abort, jsonify, send_from_directory + +import markdown +from sanitizer import sanitize_html_fragment + + +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"} + +DEFAULT_ROLE_NAME_COLORS = { + "admin": "#ff7b7b", + "moderator": "#8ab4ff", + "user": "#f5f5f7", +} + +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"} + +REACTION_EMOJI_RE = re.compile(r"^.{1,16}$") +HEX_COLOR_RE = re.compile(r"^#[0-9a-fA-F]{6}$") + + +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 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(html_module, 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 "

" + html_module.escape(bio or "").replace("\n", "
") + "

" + + +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(get_db, 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( + get_db, + upload_dir: Path, + utc_now, + orphan_ttl_hours: int, + app_logger, +) -> None: + cutoff = (utc_now() - 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: + now = time.time() + last = state.get("last_attachment_cleanup", 0.0) + + if now - last < cleanup_interval_seconds: + return + + state["last_attachment_cleanup"] = now + + try: + cleanup_func() + except Exception: + app_logger.exception("Could not clean orphaned attachments.") + + +def resolve_owned_attachment_ids( + get_db, + 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( + get_db, + 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( + get_db, + 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( + get_db, + 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( + get_db, + 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"], + ) + + +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 reaction_summary_for_message(get_db, 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 richer_message_payload( + get_db, + row, + user: Optional[dict], + current_user_id: Optional[int], +) -> 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 + if current_user_id: + seen_by_user = bool( + db.execute( + """ + SELECT 1 + FROM message_receipts + WHERE message_id = ? + AND user_id = ? + """, + (row["id"], current_user_id), + ).fetchone() + ) + + primary_attachment = attachment_payload_legacy_from_message_select_row(row) + attachments = attachment_list_for_message(get_db, 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(get_db, row["id"], user), + "is_dm": bool(("direct_conversation_id" in row.keys()) and row["direct_conversation_id"]), + } + + +def feed_message_payload( + get_db, + row, + user: Optional[dict], +) -> dict: + payload = richer_message_payload( + get_db, + row, + user, + user["id"] if user else None, + ) + + 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 + + +def profile_payload( + html_module, + 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) + + 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) + + return { + "id": user_row["id"], + "username": user_row["username"], + "role": user_row["role"], + "status": status_payload["status"], + "created_at": user_row["created_at"], + "display_name": profile_row["display_name"] if profile_row["display_name"] else user_row["username"], + "bio": bio, + "bio_mode": bio_mode, + "bio_html": safe_profile_bio_html(html_module, bio, bio_mode), + "website": profile_row["website"] or "", + "location": profile_row["location"] or "", + "updated_at": profile_row["updated_at"], + "is_self": is_self, + "name_color": explicit_name_color, + "resolved_name_color": resolved, + } + + +def list_dms_for_user(get_db, 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, + ( + 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 + 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,), + ).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"]), + }, + "last_message_text": row["last_message_text"], + "last_message_created_at": row["last_message_created_at"], + "last_message_id": row["last_message_id"], + } + ) + return items + + +def find_or_create_dm(get_db, db_write, now_iso, user_a_id: int, user_b_id: int) -> 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()), + ) + 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()), + ) + db.execute( + """ + INSERT INTO direct_conversation_members ( + direct_conversation_id, + user_id, + added_at + ) VALUES (?, ?, ?) + """, + (dm_id, high_id, now_iso()), + ) + db.commit() + return dm_id + + +def get_dm_for_user(get_db, 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(get_db, dm_id: int, user_id: int): + with get_db() as db: + row = db.execute( + """ + SELECT + u.id, + u.username, + u.role, + up.display_name, + us.name_color + 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(get_db, dm_id: int, user: Optional[dict]) -> bool: + if not user: + return False + return bool(get_dm_for_user(get_db, dm_id, user["id"])) + + +def dm_payload(get_db, dm_id: int, user_id: int) -> dict: + dm = get_dm_for_user(get_db, dm_id, user_id) + other = get_dm_other_user(get_db, 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"]), + }, + } + + +def update_presence( + db_write, + 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( + """ + 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(get_db, 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(get_db, 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(get_db, 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 + + +def create_call_session(db_write, now_iso, 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( + db_write, + now_iso, + 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(get_db, 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(db_write, now_iso, 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(get_db, 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(get_db, call_id: int) -> Optional[dict]: + row = get_call_with_dm(get_db, 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"], + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0f719c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +flask +werkzeug +gunicorn +markdown diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..e1cdd0f --- /dev/null +++ b/routes.py @@ -0,0 +1,3966 @@ +# routes.py +import json +import re +import sqlite3 +import time + +from flask import jsonify, request + +from features import ( + PAGE_TYPES, + PAGE_VISIBILITIES, + PROFILE_BIO_MODES, + REACTION_EMOJI_RE, + TEAM_MEMBER_ROLES, + THEME_MODES, + append_call_signal, + attachment_payload_from_row, + call_payload, + channel_presence_count, + cleanup_orphaned_attachments, + create_call_session, + dm_payload, + dm_presence_count, + end_call, + feed_message_payload, + find_or_create_dm, + get_call_with_dm, + get_dm_for_user, + list_call_signals, + list_dms_for_user, + normalized_hex_color, + page_payload, + profile_payload, + reaction_summary_for_message, + resolve_owned_attachment, + resolve_owned_attachment_ids, + richer_message_payload, + room_presence_count, + send_attachment_file, + update_presence, + validate_dm_access, + valid_hex_color, +) + +ROOM_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$") + + +def register_routes(app, ctx): + get_db = ctx["get_db"] + db_write = ctx["db_write"] + safe_json = ctx["safe_json"] + csrf_valid = ctx["csrf_valid"] + rate_limited = ctx["rate_limited"] + current_user = ctx["current_user"] + require_user = ctx["require_user"] + require_writable_user = ctx["require_writable_user"] + require_moderator = ctx["require_moderator"] + require_admin = ctx["require_admin"] + now_iso = ctx["now_iso"] + utc_now = ctx["utc_now"] + parse_iso = ctx["parse_iso"] + normalize_username = ctx["normalize_username"] + audit = ctx["audit"] + hash_identifier = ctx["hash_identifier"] + room_by_id = ctx["room_by_id"] + get_room_by_slug = ctx["get_room_by_slug"] + can_access_room = ctx["can_access_room"] + can_write_room = ctx["can_write_room"] + can_manage_room = ctx["can_manage_room"] + room_payload = ctx["room_payload"] + team_slug_from_name = ctx["team_slug_from_name"] + page_slug_from_title = ctx["page_slug_from_title"] + get_team_by_slug = ctx["get_team_by_slug"] + get_channel_by_id = ctx["get_channel_by_id"] + get_channel_by_slug = ctx["get_channel_by_slug"] + get_channel_by_legacy_room_id = ctx["get_channel_by_legacy_room_id"] + team_member_role = ctx["team_member_role"] + is_team_member = ctx["is_team_member"] + is_team_admin = ctx["is_team_admin"] + can_edit_team = ctx["can_edit_team"] + can_access_team = ctx["can_access_team"] + can_access_channel = ctx["can_access_channel"] + can_write_channel = ctx["can_write_channel"] + team_payload = ctx["team_payload"] + channel_payload = ctx["channel_payload"] + user_payload = ctx["user_payload"] + MESSAGE_SELECT = ctx["MESSAGE_SELECT"] + team_page_select = ctx["team_page_select"] + constants = ctx["constants"] + + 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"] + TYPING_TTL_SECONDS = constants["TYPING_TTL_SECONDS"] + PRESENCE_TTL_SECONDS = constants["PRESENCE_TTL_SECONDS"] + INVITE_TTL_HOURS = constants["INVITE_TTL_HOURS"] + TEAM_INVITE_TTL_HOURS = constants["TEAM_INVITE_TTL_HOURS"] + 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_WEBSITE_LENGTH = constants["MAX_WEBSITE_LENGTH"] + MAX_LOCATION_LENGTH = constants["MAX_LOCATION_LENGTH"] + MAX_CUSTOM_CSS_LENGTH = constants["MAX_CUSTOM_CSS_LENGTH"] + MAX_OWNED_TEAMS_PER_USER = constants["MAX_OWNED_TEAMS_PER_USER"] + ROOM_VISIBILITIES = constants["ROOM_VISIBILITIES"] + ROLES = constants["ROLES"] + STATUSES = constants["STATUSES"] + html_module = ctx["html"] + + def build_message_payload(row, user): + return richer_message_payload( + get_db, + row, + user, + user["id"] if user else None, + ) + + @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 + + ctx["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 = ctx["secure_filename"](uploaded.filename)[: constants["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 constants["ALLOWED_ATTACHMENT_EXTENSIONS"]: + return jsonify({"error": "This file type is not allowed."}), 400 + + declared_mime = (uploaded.mimetype or "").lower() + guessed_mime = ctx["mimetypes"].guess_type(original_name)[0] or "" + + if declared_mime and declared_mime not in constants["ALLOWED_ATTACHMENT_MIME_TYPES"]: + return jsonify({"error": "This file MIME type is not allowed."}), 400 + + if guessed_mime and guessed_mime not in constants["ALLOWED_ATTACHMENT_MIME_TYPES"]: + return jsonify({"error": "This file extension is not allowed."}), 400 + + content = uploaded.stream.read(constants["MAX_ATTACHMENT_BYTES"] + 1) + if not content: + return jsonify({"error": "Attachment cannot be empty."}), 400 + + if len(content) > constants["MAX_ATTACHMENT_BYTES"]: + return jsonify({"error": "Attachments must be 100 MB or smaller."}), 400 + + stored_name = f"{ctx['secrets'].token_urlsafe(24)}.{extension}" + destination = ctx["UPLOAD_DIR"] / stored_name + + try: + destination.write_bytes(content) + ctx["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//download") + def download_attachment(attachment_id: int): + user, error = require_user() + if error: + return error + + return send_attachment_file( + get_db, + ctx["UPLOAD_DIR"], + attachment_id, + user, + can_access_room, + can_access_channel, + get_channel_by_id, + ) + + @app.route("/api/rooms//messages") + def get_room_messages(slug: str): + room = get_room_by_slug(slug) + if not room: + return jsonify({"error": "Room not found."}), 404 + + user = current_user() + if not can_access_room(room, user): + if not user: + return jsonify({"error": "Login required for this private room.", "code": "login_required"}), 401 + return jsonify({"error": "You do not have access to this room.", "code": "forbidden"}), 403 + + 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.room_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + AND m.id > ? + ORDER BY m.id ASC + LIMIT ? + """, + (room["id"], after_id, MAX_INCREMENTAL_MESSAGES), + ).fetchall() + else: + rows = db.execute( + f""" + SELECT * + FROM ( + {MESSAGE_SELECT} + WHERE m.room_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + ORDER BY m.id DESC + LIMIT ? + ) + ORDER BY id ASC + """, + (room["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({"messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES}) + + @app.route("/api/rooms//messages", methods=["POST"]) + def send_room_message(slug: str): + 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 + + room = get_room_by_slug(slug) + if not room: + return jsonify({"error": "Room not found."}), 404 + + if not can_write_room(room, user): + return jsonify({"error": "You cannot post in this room.", "code": "forbidden"}), 403 + + 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(get_db, 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(get_db, 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 + + channel = get_channel_by_legacy_room_id(room["id"]) + channel_id = channel["id"] if channel else None + + 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, ?) + """, + (room["id"], channel_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("room_message_sent", user["id"], "room", str(room["id"]), str(message_id)) + return jsonify({"message": build_message_payload(row, user)}), 201 + + @app.route("/api/rooms//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_moderator() + 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/teams//channels//messages") + def api_get_channel_messages(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 + + 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.channel_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + AND m.id > ? + ORDER BY m.id ASC + LIMIT ? + """, + (channel["id"], after_id, MAX_INCREMENTAL_MESSAGES), + ).fetchall() + else: + rows = db.execute( + f""" + SELECT * + FROM ( + {MESSAGE_SELECT} + WHERE m.channel_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + ORDER BY m.id DESC + LIMIT ? + ) + ORDER BY id ASC + """, + (channel["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( + { + "team": {"slug": channel["team_slug"], "name": channel["team_name"]}, + "channel": channel_payload(channel, user), + "messages": messages, + "latest_id": latest_id, + "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES, + } + ) + + @app.route("/api/teams//channels//messages", methods=["POST"]) + def api_send_channel_message(team_slug: str, channel_slug: str): + 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 + + 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 post in this channel.", "code": "forbidden"}), 403 + + 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(get_db, 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(get_db, 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, ?) + """, + (channel["legacy_room_id"], channel["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("channel_message_sent", user["id"], "channel", str(channel["id"]), str(message_id)) + return jsonify({"message": build_message_payload(row, user)}), 201 + + @app.route("/api/messages/", methods=["PATCH"]) + def edit_message(message_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + limited = rate_limited("send") + 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 + + new_text = data.get("text", "") + if not isinstance(new_text, str): + return jsonify({"error": "Message must be text."}), 400 + new_text = new_text.strip() + + attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, data.get("attachment_ids", []), user["id"]) + if attachment_error: + return attachment_error + + if len(new_text) > MAX_MESSAGE_LENGTH: + return jsonify({"error": "Message is too long."}), 400 + + if not new_text and not attachment_ids: + return jsonify({"error": "Message cannot be empty."}), 400 + + with get_db() as db: + message = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() + + if not message: + return jsonify({"error": "Message not found."}), 404 + + is_owner = message["user_id"] == user["id"] + is_moderator = user["role"] in {"moderator", "admin"} + + if not is_owner and not is_moderator: + return jsonify({"error": "You cannot edit this message.", "code": "forbidden"}), 403 + + created_at = parse_iso(message["created_at"]) + if is_owner and created_at: + age = (utc_now() - created_at).total_seconds() + if age > MAX_MESSAGE_EDIT_WINDOW_SECONDS: + return jsonify({"error": "Edit window has expired."}), 403 + + old_attachments = ctx["attachment_list_for_message"](message_id) + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + db.execute("UPDATE messages SET text = ?, attachment_id = ? WHERE id = ?", (new_text, attachment_ids[0] if attachment_ids else None, message_id)) + db.execute("DELETE FROM message_attachments WHERE message_id = ?", (message_id,)) + + for position, attachment_id in enumerate(attachment_ids): + db.execute( + """ + INSERT INTO message_attachments ( + message_id, + attachment_id, + position + ) VALUES (?, ?, ?) + """, + (message_id, attachment_id, position), + ) + + db.execute( + """ + INSERT INTO message_edits ( + message_id, + edited_by_user_id, + previous_text, + new_text, + previous_attachments_json, + new_attachments_json, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + message_id, + user["id"], + message["text"], + new_text, + json.dumps(old_attachments), + json.dumps(attachment_ids), + now_iso(), + ), + ) + db.commit() + + with get_db() as db: + updated = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (message_id,)).fetchone() + + audit("message_edited", user["id"], "message", str(message_id)) + return jsonify({"message": build_message_payload(updated, user)}) + + @app.route("/api/messages/", methods=["DELETE"]) + def delete_message(message_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + moderator, error = require_moderator() + if error: + return error + + limited = rate_limited("admin") + if limited: + return limited + + cursor = db_write("DELETE FROM messages WHERE id = ?", (message_id,)) + if cursor.rowcount == 0: + return jsonify({"error": "Message not found."}), 404 + + audit("message_deleted", moderator["id"], "message", str(message_id)) + return jsonify({"status": "deleted"}) + + @app.route("/api/messages/history") + def api_message_history(): + user = current_user() + kind = str(request.args.get("kind", "")).strip() + slug = str(request.args.get("slug", "")).strip() + team_slug = str(request.args.get("team_slug", "")).strip() + before_id_raw = str(request.args.get("before_id", "")).strip() + + try: + before_id = int(before_id_raw) + except ValueError: + return jsonify({"error": "before_id is required."}), 400 + + if kind == "room": + room = get_room_by_slug(slug) + if not room or not can_access_room(room, user): + return jsonify({"error": "Room not found."}), 404 + + with get_db() as db: + rows = db.execute( + f""" + SELECT * + FROM ( + {MESSAGE_SELECT} + WHERE m.room_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ) + ORDER BY id ASC + """, + (room["id"], before_id, MAX_HISTORY_MESSAGES), + ).fetchall() + + elif kind == "channel": + channel = get_channel_by_slug(team_slug, slug) + if not channel or not can_access_channel(channel, user): + return jsonify({"error": "Channel not found."}), 404 + + with get_db() as db: + rows = db.execute( + f""" + SELECT * + FROM ( + {MESSAGE_SELECT} + WHERE m.channel_id = ? + AND m.parent_message_id IS NULL + AND m.direct_conversation_id IS NULL + AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ) + ORDER BY id ASC + """, + (channel["id"], before_id, MAX_HISTORY_MESSAGES), + ).fetchall() + elif kind == "dm": + dm_id_raw = str(request.args.get("dm_id", "")).strip() + try: + dm_id = int(dm_id_raw) + except ValueError: + return jsonify({"error": "dm_id is required for DM history."}), 400 + + if not validate_dm_access(get_db, dm_id, user): + return jsonify({"error": "DM not found."}), 404 + + with get_db() as db: + rows = db.execute( + f""" + SELECT * + FROM ( + {MESSAGE_SELECT} + WHERE m.direct_conversation_id = ? + AND m.parent_message_id IS NULL + AND m.id < ? + ORDER BY m.id DESC + LIMIT ? + ) + ORDER BY id ASC + """, + (dm_id, before_id, MAX_HISTORY_MESSAGES), + ).fetchall() + else: + return jsonify({"error": "Invalid history kind."}), 400 + + messages = [build_message_payload(row, user) for row in rows] + return jsonify({"messages": messages, "has_more": len(messages) == MAX_HISTORY_MESSAGES}) + + @app.route("/api/messages//thread") + def api_get_message_thread(message_id: int): + user = current_user() + + with get_db() as db: + root = db.execute( + f""" + {MESSAGE_SELECT} + WHERE m.id = ? + AND m.parent_message_id IS NULL + """, + (message_id,), + ).fetchone() + + if not root: + return jsonify({"error": "Thread root message not found."}), 404 + + if root["direct_conversation_id"]: + if not validate_dm_access(get_db, root["direct_conversation_id"], user): + if not user: + return jsonify({"error": "Login required.", "code": "login_required"}), 401 + return jsonify({"error": "You do not have access to this thread.", "code": "forbidden"}), 403 + else: + channel = None + if root["channel_id"]: + channel = get_channel_by_id(root["channel_id"]) + if not channel and root["room_id"]: + channel = get_channel_by_legacy_room_id(root["room_id"]) + + if not channel or 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 thread.", "code": "forbidden"}), 403 + + with get_db() as db: + replies = db.execute( + f""" + {MESSAGE_SELECT} + WHERE m.parent_message_id = ? + ORDER BY m.id ASC + LIMIT ? + """, + (message_id, MAX_THREAD_REPLIES), + ).fetchall() + + payload = { + "root": build_message_payload(root, user), + "replies": [build_message_payload(reply, user) for reply in replies], + } + + if root["direct_conversation_id"]: + payload["dm"] = dm_payload(get_db, root["direct_conversation_id"], user["id"]) + else: + channel = get_channel_by_id(root["channel_id"]) if root["channel_id"] else get_channel_by_legacy_room_id(root["room_id"]) + payload["team"] = {"slug": channel["team_slug"], "name": channel["team_name"]} + payload["channel"] = channel_payload(channel, user) + + return jsonify(payload) + + @app.route("/api/messages//thread", methods=["POST"]) + def api_reply_to_thread(message_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 + + with get_db() as db: + root = db.execute( + """ + SELECT + id, + room_id, + channel_id, + direct_conversation_id, + parent_message_id + FROM messages + WHERE id = ? + """, + (message_id,), + ).fetchone() + + if not root or root["parent_message_id"] is not None: + return jsonify({"error": "Thread root message not found."}), 404 + + if root["direct_conversation_id"]: + if not validate_dm_access(get_db, root["direct_conversation_id"], user): + return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 + else: + channel = None + if root["channel_id"]: + channel = get_channel_by_id(root["channel_id"]) + if not channel and root["room_id"]: + channel = get_channel_by_legacy_room_id(root["room_id"]) + + if not channel or not can_write_channel(channel, user): + return jsonify({"error": "You cannot reply in this thread.", "code": "forbidden"}), 403 + + 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": "Reply must be text."}), 400 + text = text.strip() + + if len(text) > MAX_MESSAGE_LENGTH: + return jsonify({"error": "Reply is too long."}), 400 + + attachment_ids, attachment_error = resolve_owned_attachment_ids(get_db, 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(get_db, 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": "Reply 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 (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + root["room_id"], + root["channel_id"], + root["direct_conversation_id"], + user["id"], + user["username"], + text, + attachment_id, + root["id"], + now_iso(), + ), + ) + reply_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 (?, ?, ?) + """, + (reply_id, extra_attachment_id, position), + ) + db.commit() + + with get_db() as db: + reply = db.execute(f"{MESSAGE_SELECT} WHERE m.id = ?", (reply_id,)).fetchone() + + audit("thread_reply_sent", user["id"], "message", str(message_id), str(reply_id)) + return jsonify({"reply": build_message_payload(reply, user)}), 201 + + @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 = [feed_message_payload(get_db, 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 = [feed_message_payload(get_db, 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}) + + @app.route("/api/search") + def api_search(): + user = current_user() + query = str(request.args.get("q", "")).strip() + + if not query: + return jsonify({"results": [], "query": ""}) + + if len(query) > MAX_SEARCH_QUERY_LENGTH: + return jsonify({"error": "Search query is too long."}), 400 + + pattern = f"%{query.lower()}%" + + with get_db() as db: + rows = db.execute( + """ + 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 + LEFT JOIN teams AS t + ON t.id = c.team_id + LEFT JOIN attachments AS a + ON a.id = m.attachment_id + WHERE LOWER(m.text) LIKE ? + ORDER BY m.id DESC + LIMIT ? + """, + (pattern, MAX_SEARCH_RESULTS), + ).fetchall() + + visible = [] + for row in rows: + allowed = False + if row["direct_conversation_id"]: + allowed = bool(user and validate_dm_access(get_db, row["direct_conversation_id"], user)) + elif row["channel_id"]: + channel = get_channel_by_id(row["channel_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + elif row["room_id"]: + room = room_by_id(row["room_id"]) + allowed = bool(room and can_access_room(room, user)) + + if allowed: + visible.append(feed_message_payload(get_db, row, user)) + + return jsonify({"query": query, "results": visible}) + + @app.route("/api/read-receipts", methods=["POST"]) + def api_mark_read_receipts(): + 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 + + ids = data.get("message_ids", []) + if not isinstance(ids, list): + return jsonify({"error": "message_ids must be an array."}), 400 + + parsed_ids = [] + for item in ids[:READ_RECEIPT_BATCH_LIMIT]: + try: + parsed_ids.append(int(item)) + except (TypeError, ValueError): + continue + + if not parsed_ids: + return jsonify({"status": "ok", "updated": 0}) + + updated = 0 + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + for message_id in parsed_ids: + row = db.execute( + """ + SELECT id, room_id, channel_id, direct_conversation_id + FROM messages + WHERE id = ? + """, + (message_id,), + ).fetchone() + + if not row: + continue + + allowed = False + if row["direct_conversation_id"]: + allowed = validate_dm_access(get_db, row["direct_conversation_id"], user) + elif row["channel_id"]: + channel = get_channel_by_id(row["channel_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + elif row["room_id"]: + room = room_by_id(row["room_id"]) + allowed = bool(room and can_access_room(room, user)) + + if not allowed: + continue + + db.execute( + """ + INSERT INTO message_receipts ( + message_id, + user_id, + seen_at + ) VALUES (?, ?, ?) + ON CONFLICT(message_id, user_id) + DO UPDATE SET seen_at = excluded.seen_at + """, + (message_id, user["id"], now_iso()), + ) + updated += 1 + db.commit() + + return jsonify({"status": "ok", "updated": updated}) + + @app.route("/api/messages//receipts") + def api_get_message_receipts(message_id: int): + user, error = require_user() + if error: + return error + + with get_db() as db: + message = db.execute( + """ + SELECT id, room_id, channel_id, direct_conversation_id, user_id + FROM messages + WHERE id = ? + """, + (message_id,), + ).fetchone() + + if not message: + return jsonify({"error": "Message not found."}), 404 + + allowed = False + if message["direct_conversation_id"]: + allowed = validate_dm_access(get_db, message["direct_conversation_id"], user) + elif message["channel_id"]: + channel = get_channel_by_id(message["channel_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + elif message["room_id"]: + room = room_by_id(message["room_id"]) + allowed = bool(room and can_access_room(room, user)) + + if not allowed: + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + with get_db() as db: + rows = db.execute( + """ + SELECT + u.id, + u.username, + mr.seen_at + FROM message_receipts AS mr + JOIN users AS u + ON u.id = mr.user_id + WHERE mr.message_id = ? + ORDER BY mr.seen_at DESC, u.username COLLATE NOCASE + """, + (message_id,), + ).fetchall() + + return jsonify( + { + "message_id": message_id, + "receipts": [{"user_id": row["id"], "username": row["username"], "seen_at": row["seen_at"]} for row in rows], + } + ) + + @app.route("/api/messages//reactions") + def api_get_message_reactions(message_id: int): + user = current_user() + + with get_db() as db: + row = db.execute( + """ + SELECT + id, + room_id, + channel_id, + direct_conversation_id + FROM messages + WHERE id = ? + """, + (message_id,), + ).fetchone() + + if not row: + return jsonify({"error": "Message not found."}), 404 + + allowed = False + if row["direct_conversation_id"]: + allowed = bool(user and validate_dm_access(get_db, row["direct_conversation_id"], user)) + elif row["channel_id"]: + channel = get_channel_by_id(row["channel_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + elif row["room_id"]: + room = room_by_id(row["room_id"]) + allowed = bool(room and can_access_room(room, user)) + + if not allowed: + if not user: + return jsonify({"error": "Login required.", "code": "login_required"}), 401 + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + with get_db() as db: + detail_rows = db.execute( + """ + SELECT + mr.emoji, + u.id AS user_id, + u.username, + mr.created_at + FROM message_reactions AS mr + JOIN users AS u + ON u.id = mr.user_id + WHERE mr.message_id = ? + ORDER BY mr.emoji ASC, u.username COLLATE NOCASE + """, + (message_id,), + ).fetchall() + + grouped = {} + for item in detail_rows: + grouped.setdefault(item["emoji"], []).append( + { + "user_id": item["user_id"], + "username": item["username"], + "created_at": item["created_at"], + } + ) + + summary = [] + for emoji, users in grouped.items(): + summary.append( + { + "emoji": emoji, + "count": len(users), + "reacted": bool(user and any(x["user_id"] == user["id"] for x in users)), + "users": users, + } + ) + + summary.sort(key=lambda x: (-x["count"], x["emoji"])) + return jsonify({"message_id": message_id, "reactions": summary}) + + @app.route("/api/messages//reactions", methods=["POST"]) + def api_add_reaction(message_id: int): + limited = rate_limited("reaction") + 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 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + emoji = str(data.get("emoji", "")).strip() + if not emoji or len(emoji) > 16 or not REACTION_EMOJI_RE.fullmatch(emoji): + return jsonify({"error": "Invalid reaction."}), 400 + + with get_db() as db: + row = db.execute( + """ + SELECT + id, + room_id, + channel_id, + direct_conversation_id + FROM messages + WHERE id = ? + """, + (message_id,), + ).fetchone() + + if not row: + return jsonify({"error": "Message not found."}), 404 + + allowed = False + if row["direct_conversation_id"]: + allowed = validate_dm_access(get_db, row["direct_conversation_id"], user) + elif row["channel_id"]: + channel = get_channel_by_id(row["channel_id"]) + allowed = bool(channel and can_access_channel(channel, user)) + elif row["room_id"]: + room = room_by_id(row["room_id"]) + allowed = bool(room and can_access_room(room, user)) + + if not allowed: + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + db_write( + """ + INSERT OR IGNORE INTO message_reactions ( + message_id, + user_id, + emoji, + created_at + ) VALUES (?, ?, ?, ?) + """, + (message_id, user["id"], emoji, now_iso()), + ) + + audit("message_reaction_added", user["id"], "message", str(message_id), emoji) + return jsonify({"message_id": message_id, "reactions": reaction_summary_for_message(get_db, message_id, user)}), 201 + + @app.route("/api/messages//reactions/", methods=["DELETE"]) + def api_remove_reaction(message_id: int, emoji: str): + limited = rate_limited("reaction") + 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 + + db_write( + """ + DELETE FROM message_reactions + WHERE message_id = ? + AND user_id = ? + AND emoji = ? + """, + (message_id, user["id"], emoji), + ) + + audit("message_reaction_removed", user["id"], "message", str(message_id), emoji) + return jsonify({"message_id": message_id, "reactions": reaction_summary_for_message(get_db, message_id, user)}) + + @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//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(get_db, PRESENCE_TTL_SECONDS, room["id"])}) + + @app.route("/api/teams//channels//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(get_db, PRESENCE_TTL_SECONDS, channel["id"])}) + + @app.route("/api/dms//presence") + def api_dm_presence(dm_id: int): + user = current_user() + if not validate_dm_access(get_db, dm_id, user): + return jsonify({"error": "DM not found."}), 404 + return jsonify({"dm_id": dm_id, "online_count": dm_presence_count(get_db, PRESENCE_TTL_SECONDS, dm_id)}) + + @app.route("/api/rooms//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//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//channels//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//channels//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//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(get_db, 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//typing") + def api_get_dm_typing(dm_id: int): + user = current_user() + if not validate_dm_access(get_db, 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]}) + + @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/", 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/", 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//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//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//members/", 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//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 = ctx["secrets"].token_urlsafe(32) + expires_at = (utc_now() + ctx["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//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)}) + + @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/", 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"}) + + @app.route("/api/teams") + def api_list_teams(): + user = current_user() + + with get_db() as db: + teams = db.execute( + """ + SELECT + id, + slug, + name, + description, + created_by_user_id, + created_at, + is_active + FROM teams + WHERE is_active = 1 + ORDER BY name COLLATE NOCASE + """ + ).fetchall() + + channels = 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.is_active = 1 + AND t.is_active = 1 + """ + ).fetchall() + + public_page_rows = db.execute( + """ + SELECT DISTINCT p.team_id + FROM pages AS p + JOIN teams AS t + ON t.id = p.team_id + WHERE p.is_active = 1 + AND p.visibility = 'public' + AND t.is_active = 1 + """ + ).fetchall() + + channels_by_team = {} + for channel in channels: + if can_access_channel(channel, user): + channels_by_team[channel["team_id"]] = True + + public_page_team_ids = {row["team_id"] for row in public_page_rows} + + visible_teams = [ + team_payload(team, user) + for team in teams + if can_access_team(team, user) or channels_by_team.get(team["id"]) or team["id"] in public_page_team_ids + ] + + return jsonify({"teams": visible_teams, "authenticated": bool(user)}) + + @app.route("/api/teams", methods=["POST"]) + def api_create_team(): + 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 + + with get_db() as db: + owned_count = db.execute( + """ + SELECT COUNT(*) AS count + FROM team_members + WHERE user_id = ? + AND membership_role = 'owner' + """, + (user["id"],), + ).fetchone()["count"] + + if owned_count >= MAX_OWNED_TEAMS_PER_USER: + return jsonify({"error": "You can own at most 3 teams."}), 400 + + 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] + requested_slug = str(data.get("slug", "")).strip().lower() + + if not name or len(name) > 60: + return jsonify({"error": "Team name must be 1 to 60 characters."}), 400 + + base_slug = requested_slug or team_slug_from_name(name) + if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug): + return jsonify({"error": "Team name cannot create a valid slug."}), 400 + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + slug = base_slug + suffix = 2 + + while db.execute("SELECT 1 FROM teams WHERE slug = ?", (slug,)).fetchone(): + slug = f"{base_slug[:44]}-{suffix}" + suffix += 1 + + cursor = db.execute( + """ + INSERT INTO teams ( + slug, + name, + description, + created_by_user_id, + created_at, + is_active + ) VALUES (?, ?, ?, ?, ?, 1) + """, + (slug, name, description, user["id"], now_iso()), + ) + team_id = cursor.lastrowid + + db.execute( + """ + INSERT INTO team_members ( + team_id, + user_id, + membership_role, + added_at + ) VALUES (?, ?, 'owner', ?) + """, + (team_id, user["id"], now_iso()), + ) + db.commit() + + audit("team_created", user["id"], "team", str(team_id), slug) + + with get_db() as db: + team = db.execute( + """ + SELECT + id, + slug, + name, + description, + created_by_user_id, + created_at, + is_active + FROM teams + WHERE id = ? + """, + (team_id,), + ).fetchone() + + return jsonify({"team": team_payload(team, user)}), 201 + + @app.route("/api/teams/", methods=["PATCH"]) + def api_update_team(team_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 + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not can_edit_team(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + name = str(data.get("name", team["name"])).strip() + description = str(data.get("description", team["description"] or "")).strip()[:300] + + if not name or len(name) > 60: + return jsonify({"error": "Team name must be 1 to 60 characters."}), 400 + + db_write("UPDATE teams SET name = ?, description = ? WHERE id = ?", (name, description, team["id"])) + updated = get_team_by_slug(team_slug) + + audit("team_updated", user["id"], "team", str(team["id"]), f"name={name}") + return jsonify({"team": team_payload(updated, user)}) + + @app.route("/api/teams//channels") + def api_list_team_channels(team_slug: str): + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + user = current_user() + + with get_db() as db: + rows = 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.team_id = ? + AND c.is_active = 1 + AND t.is_active = 1 + ORDER BY + CASE + WHEN c.slug = 'general' THEN 0 + ELSE 1 + END, + c.name COLLATE NOCASE + """, + (team["id"],), + ).fetchall() + + channels = [channel_payload(channel, user) for channel in rows if can_access_channel(channel, user)] + + if not channels and not can_access_team(team, user): + if not user: + return jsonify({"error": "Login required.", "code": "login_required"}), 401 + return jsonify({"error": "You do not have access to this team.", "code": "forbidden"}), 403 + + return jsonify({"team": team_payload(team, user), "channels": channels}) + + @app.route("/api/teams//channels", methods=["POST"]) + def api_create_channel(team_slug: str): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + 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] + requested_slug = str(data.get("slug", "")).strip().lower() + + if not name or len(name) > 60: + return jsonify({"error": "Channel name must be 1 to 60 characters."}), 400 + + base_slug = requested_slug or team_slug_from_name(name) + if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug): + return jsonify({"error": "Channel name cannot create a valid slug."}), 400 + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + slug = base_slug + suffix = 2 + + while db.execute("SELECT 1 FROM channels WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): + slug = f"{base_slug[:44]}-{suffix}" + suffix += 1 + + cursor = db.execute( + """ + INSERT INTO channels ( + team_id, + legacy_room_id, + slug, + name, + description, + created_by_user_id, + created_at, + is_active + ) VALUES (?, NULL, ?, ?, ?, ?, ?, 1) + """, + (team["id"], slug, name, description, user["id"], now_iso()), + ) + channel_id = cursor.lastrowid + db.commit() + + audit("channel_created", user["id"], "channel", str(channel_id), f"{team_slug}/{slug}") + channel = get_channel_by_id(channel_id) + return jsonify({"channel": channel_payload(channel, user)}), 201 + + @app.route("/api/teams//channels/", methods=["PATCH"]) + def api_update_channel(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("admin") + 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_edit_team(channel["team_id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + name = str(data.get("name", channel["name"])).strip() + description = str(data.get("description", channel["description"] or "")).strip()[:300] + + if not name or len(name) > 60: + return jsonify({"error": "Channel name must be 1 to 60 characters."}), 400 + + db_write("UPDATE channels SET name = ?, description = ? WHERE id = ?", (name, description, channel["id"])) + + if channel["legacy_room_id"]: + db_write("UPDATE rooms SET name = ?, description = ? WHERE id = ?", (name, description, channel["legacy_room_id"])) + + updated = get_channel_by_id(channel["id"]) + audit("channel_updated", user["id"], "channel", str(channel["id"]), f"{team_slug}/{channel_slug}") + return jsonify({"channel": channel_payload(updated, user)}) + + @app.route("/api/teams//channels/", methods=["DELETE"]) + def api_delete_channel(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("admin") + 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_edit_team(channel["team_id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + if channel["legacy_room_id"]: + return jsonify({"error": "Legacy channels linked to rooms cannot be deleted here."}), 400 + + db_write("UPDATE channels SET is_active = 0 WHERE id = ?", (channel["id"],)) + audit("channel_deleted", user["id"], "channel", str(channel["id"]), f"{team_slug}/{channel_slug}") + return jsonify({"status": "deleted"}) + + @app.route("/api/teams//members") + def api_list_team_members(team_slug: str): + user, error = require_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + with get_db() as db: + rows = db.execute( + """ + SELECT + u.id, + u.username, + u.role, + tm.membership_role, + tm.added_at + FROM team_members AS tm + JOIN users AS u + ON u.id = tm.user_id + WHERE tm.team_id = ? + ORDER BY + CASE tm.membership_role + WHEN 'owner' THEN 0 + WHEN 'admin' THEN 1 + ELSE 2 + END, + u.username COLLATE NOCASE + """, + (team["id"],), + ).fetchall() + + return jsonify({"team": team_payload(team, user), "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/teams//members", methods=["POST"]) + def api_add_team_member(team_slug: str): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + username = normalize_username(data.get("username")) + membership_role = str(data.get("membership_role", "member")).strip().lower() + + if membership_role not in TEAM_MEMBER_ROLES: + return jsonify({"error": "Invalid membership role."}), 400 + + 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 + + if membership_role == "owner" and team_member_role(team["id"], user["id"]) != "owner": + return jsonify({"error": "Only an owner can assign owner role."}), 403 + + db_write( + """ + INSERT OR IGNORE INTO team_members ( + team_id, + user_id, + membership_role, + added_at + ) VALUES (?, ?, ?, ?) + """, + (team["id"], target["id"], membership_role, now_iso()), + ) + + audit("team_member_added", user["id"], "team", str(team["id"]), f"{target['username']}:{membership_role}") + return jsonify({"status": "added"}), 201 + + @app.route("/api/teams//members/", methods=["PATCH"]) + def api_update_team_member(team_slug: str, member_user_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + membership_role = str(data.get("membership_role", "")).strip().lower() + if membership_role not in TEAM_MEMBER_ROLES: + return jsonify({"error": "Invalid membership role."}), 400 + + actor_role = team_member_role(team["id"], user["id"]) + target_role = team_member_role(team["id"], member_user_id) + + if not target_role: + return jsonify({"error": "Team member not found."}), 404 + + if target_role == "owner" and actor_role != "owner": + return jsonify({"error": "Only an owner can modify another owner."}), 403 + + if membership_role == "owner" and actor_role != "owner": + return jsonify({"error": "Only an owner can assign owner role."}), 403 + + db_write("UPDATE team_members SET membership_role = ? WHERE team_id = ? AND user_id = ?", (membership_role, team["id"], member_user_id)) + audit("team_member_role_updated", user["id"], "team", str(team["id"]), f"user_id={member_user_id};role={membership_role}") + return jsonify({"status": "updated"}) + + @app.route("/api/teams//members/", methods=["DELETE"]) + def api_remove_team_member(team_slug: str, member_user_id: int): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + actor_role = team_member_role(team["id"], user["id"]) + target_role = team_member_role(team["id"], member_user_id) + + if not target_role: + return jsonify({"error": "Team member not found."}), 404 + + if target_role == "owner": + return jsonify({"error": "The team owner cannot be removed."}), 400 + + if target_role == "admin" and actor_role not in {"owner", "admin"}: + return jsonify({"error": "Forbidden.", "code": "forbidden"}), 403 + + db_write("DELETE FROM team_members WHERE team_id = ? AND user_id = ?", (team["id"], member_user_id)) + audit("team_member_removed", user["id"], "team", str(team["id"]), str(member_user_id)) + return jsonify({"status": "removed"}) + + @app.route("/api/teams//invites", methods=["POST"]) + def api_create_team_invite(team_slug: str): + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + membership_role = str(data.get("membership_role", "member")).strip().lower() + if membership_role not in {"member", "admin"}: + return jsonify({"error": "Invite role must be member or admin."}), 400 + + token = ctx["secrets"].token_urlsafe(32) + expires_at = (utc_now() + ctx["timedelta"](hours=TEAM_INVITE_TTL_HOURS)).isoformat() + + db_write( + """ + INSERT INTO team_invites ( + team_id, + token_hash, + created_by_user_id, + membership_role, + created_at, + expires_at + ) VALUES (?, ?, ?, ?, ?, ?) + """, + (team["id"], hash_identifier(token), user["id"], membership_role, now_iso(), expires_at), + ) + + audit("team_invite_created", user["id"], "team", str(team["id"]), membership_role) + return jsonify({"invite": {"url": f"/?team_invite={token}", "expires_at": expires_at, "membership_role": membership_role}}), 201 + + @app.route("/api/team-invites//accept", methods=["POST"]) + def api_accept_team_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 + ti.id, + ti.team_id, + ti.membership_role, + ti.expires_at, + ti.used_at, + t.slug, + t.name, + t.description, + t.is_active + FROM team_invites AS ti + JOIN teams AS t + ON t.id = ti.team_id + WHERE ti.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 not invite["is_active"]: + return jsonify({"error": "This team is no longer available."}), 404 + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + fresh = db.execute("SELECT id, used_at, expires_at FROM team_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 INTO team_members ( + team_id, + user_id, + membership_role, + added_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(team_id, user_id) + DO UPDATE SET membership_role = excluded.membership_role + """, + (invite["team_id"], user["id"], invite["membership_role"], now_iso()), + ) + + db.execute( + """ + UPDATE team_invites + SET used_at = ?, used_by_user_id = ? + WHERE id = ? + """, + (now_iso(), user["id"], invite["id"]), + ) + db.commit() + + audit("team_invite_accepted", user["id"], "team", str(invite["team_id"])) + team = get_team_by_slug(invite["slug"]) + return jsonify({"status": "joined", "team": team_payload(team, user)}) + + @app.route("/api/teams//pages") + def api_list_pages(team_slug: str): + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + user = current_user() + page_type = request.args.get("type", "").strip().lower() + visibility = request.args.get("visibility", "").strip().lower() + + if page_type and page_type not in PAGE_TYPES: + return jsonify({"error": "Invalid page type."}), 400 + + if visibility and visibility not in PAGE_VISIBILITIES: + return jsonify({"error": "Invalid page visibility."}), 400 + + is_member = bool(user and (user["role"] in {"moderator", "admin"} or is_team_member(team["id"], user["id"]))) + + conditions = ["p.team_id = ?", "p.is_active = 1", "t.is_active = 1"] + params = [team["id"]] + + if page_type: + conditions.append("p.page_type = ?") + params.append(page_type) + + if visibility: + conditions.append("p.visibility = ?") + params.append(visibility) + + if not is_member: + conditions.append("p.visibility = 'public'") + + query = f""" + {team_page_select()} + WHERE {" AND ".join(conditions)} + ORDER BY p.updated_at DESC, p.id DESC + """ + + with get_db() as db: + rows = db.execute(query, tuple(params)).fetchall() + + return jsonify({"team": team_payload(team, user), "pages": [page_payload(page, user) for page in rows]}) + + @app.route("/api/teams//pages", methods=["POST"]) + def api_create_page(): + team_slug = request.view_args["team_slug"] + if not csrf_valid(): + return jsonify({"error": "Security token expired.", "code": "csrf"}), 403 + + user, error = require_writable_user() + if error: + return error + + team = get_team_by_slug(team_slug) + if not team: + return jsonify({"error": "Team not found."}), 404 + + if not is_team_admin(team["id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + title = str(data.get("title", "")).strip() + body = str(data.get("body", "")).strip() + visibility = str(data.get("visibility", "team")).strip().lower() + page_type = str(data.get("page_type", "document")).strip().lower() + requested_slug = str(data.get("slug", "")).strip().lower() + channel_slug = str(data.get("channel_slug", "")).strip().lower() + + if not title or len(title) > 160: + return jsonify({"error": "Page title must be 1 to 160 characters."}), 400 + if len(body) > 100000: + return jsonify({"error": "Page content is too long."}), 400 + if visibility not in PAGE_VISIBILITIES: + return jsonify({"error": "Invalid page visibility."}), 400 + if page_type not in PAGE_TYPES: + return jsonify({"error": "Invalid page type."}), 400 + + base_slug = requested_slug or page_slug_from_title(title) + if not base_slug or not ROOM_SLUG_RE.fullmatch(base_slug): + return jsonify({"error": "Page title cannot create a valid slug."}), 400 + + channel_id = None + if channel_slug: + channel = get_channel_by_slug(team_slug, channel_slug) + if not channel: + return jsonify({"error": "Channel not found."}), 404 + channel_id = channel["id"] + + with get_db() as db: + db.execute("BEGIN IMMEDIATE") + slug = base_slug + suffix = 2 + + while db.execute("SELECT 1 FROM pages WHERE team_id = ? AND slug = ?", (team["id"], slug)).fetchone(): + slug = f"{base_slug[:44]}-{suffix}" + suffix += 1 + + cursor = db.execute( + """ + INSERT INTO pages ( + team_id, + channel_id, + slug, + title, + body, + visibility, + page_type, + created_by_user_id, + updated_by_user_id, + created_at, + updated_at, + is_active + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + """, + (team["id"], channel_id, slug, title, body, visibility, page_type, user["id"], user["id"], now_iso(), now_iso()), + ) + page_id = cursor.lastrowid + db.commit() + + audit("page_created", user["id"], "page", str(page_id), f"{team_slug}/{slug}") + + with get_db() as db: + page = db.execute(f"{team_page_select()} WHERE p.id = ?", (page_id,)).fetchone() + + return jsonify({"page": page_payload(page, user)}), 201 + + @app.route("/api/teams//pages/") + def api_get_page(team_slug: str, page_slug: str): + user = current_user() + 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() + + if not page: + return jsonify({"error": "Page not found."}), 404 + + permitted = bool(page["visibility"] == "public" or (user and (user["role"] in {"moderator", "admin"} or is_team_member(page["team_id"], user["id"])))) + + if not permitted: + if not user: + return jsonify({"error": "Login required.", "code": "login_required"}), 401 + return jsonify({"error": "You do not have access to this page.", "code": "forbidden"}), 403 + + return jsonify({"page": page_payload(page, user)}) + + @app.route("/api/teams//pages/", methods=["PATCH"]) + def api_update_page(team_slug: str, page_slug: str): + 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: + 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() + + if not page: + return jsonify({"error": "Page not found."}), 404 + + if not is_team_admin(page["team_id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + try: + data = safe_json() + except ValueError as error: + return jsonify({"error": str(error)}), 400 + + title = str(data.get("title", page["title"])).strip() + body = str(data.get("body", page["body"])).strip() + visibility = str(data.get("visibility", page["visibility"])).strip().lower() + page_type = str(data.get("page_type", page["page_type"])).strip().lower() + channel_slug = str(data.get("channel_slug", "")).strip().lower() + + if not title or len(title) > 160: + return jsonify({"error": "Page title must be 1 to 160 characters."}), 400 + if len(body) > 100000: + return jsonify({"error": "Page content is too long."}), 400 + if visibility not in PAGE_VISIBILITIES: + return jsonify({"error": "Invalid page visibility."}), 400 + if page_type not in PAGE_TYPES: + return jsonify({"error": "Invalid page type."}), 400 + + channel_id = page["channel_id"] + if "channel_slug" in data: + if channel_slug: + channel = get_channel_by_slug(team_slug, channel_slug) + if not channel: + return jsonify({"error": "Channel not found."}), 404 + channel_id = channel["id"] + else: + channel_id = None + + db_write( + """ + UPDATE pages + SET + title = ?, + body = ?, + visibility = ?, + page_type = ?, + channel_id = ?, + updated_by_user_id = ?, + updated_at = ? + WHERE id = ? + """, + (title, body, visibility, page_type, channel_id, user["id"], now_iso(), page["id"]), + ) + + audit("page_updated", user["id"], "page", str(page["id"]), f"{team_slug}/{page_slug}") + + with get_db() as db: + updated = db.execute(f"{team_page_select()} WHERE p.id = ?", (page["id"],)).fetchone() + + return jsonify({"page": page_payload(updated, user)}) + + @app.route("/api/teams//pages/", methods=["DELETE"]) + def api_delete_page(team_slug: str, page_slug: str): + 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: + page = db.execute( + """ + SELECT + p.id, + p.team_id + FROM pages AS p + JOIN teams AS t + ON t.id = p.team_id + WHERE t.slug = ? + AND p.slug = ? + AND p.is_active = 1 + AND t.is_active = 1 + """, + (team_slug, page_slug), + ).fetchone() + + if not page: + return jsonify({"error": "Page not found."}), 404 + + if not is_team_admin(page["team_id"], user): + return jsonify({"error": "Team admin access required.", "code": "forbidden"}), 403 + + db_write( + """ + UPDATE pages + SET + is_active = 0, + updated_by_user_id = ?, + updated_at = ? + WHERE id = ? + """, + (user["id"], now_iso(), page["id"]), + ) + + audit("page_deleted", user["id"], "page", str(page["id"]), f"{team_slug}/{page_slug}") + return jsonify({"status": "deleted"}) + + @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 + FROM users + WHERE id = ? + """, + (user["id"],), + ).fetchone() + + profile_row = db.execute( + """ + SELECT + user_id, + display_name, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() + + settings_row = db.execute( + """ + SELECT + name_color + FROM user_settings + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() + + return jsonify({"profile": profile_payload(html_module, 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() + 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() + + if len(display_name) > MAX_DISPLAY_NAME_LENGTH: + return jsonify({"error": "Display name 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 + + db_write( + """ + UPDATE user_profiles + SET + display_name = ?, + bio = ?, + bio_mode = ?, + website = ?, + location = ?, + updated_at = ? + WHERE user_id = ? + """, + (display_name or user["username"], bio, bio_mode, website, location, now_iso(), user["id"]), + ) + + with get_db() as db: + user_row = db.execute( + """ + SELECT + id, + username, + role, + status, + restricted_until, + created_at + FROM users + WHERE id = ? + """, + (user["id"],), + ).fetchone() + + profile_row = db.execute( + """ + SELECT + user_id, + display_name, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user["id"],), + ).fetchone() + + settings_row = db.execute("SELECT name_color FROM user_settings WHERE user_id = ?", (user["id"],)).fetchone() + + audit("profile_updated", user["id"], "user", str(user["id"])) + return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, user)}) + + @app.route("/api/profiles/") + def api_get_public_profile(username: str): + normalized = normalize_username(username) + + with get_db() as db: + user_row = db.execute( + """ + SELECT + id, + username, + role, + status, + restricted_until, + created_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, + bio, + bio_mode, + website, + location, + updated_at + FROM user_profiles + WHERE user_id = ? + """, + (user_row["id"],), + ).fetchone() + + settings_row = db.execute("SELECT name_color FROM user_settings WHERE user_id = ?", (user_row["id"],)).fetchone() + + return jsonify({"profile": profile_payload(html_module, user_payload, user_row, profile_row, settings_row, current_user())}) + + @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, + 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"], + "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() + + 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 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 + + db_write( + """ + UPDATE user_settings + SET + accent_color = ?, + name_color = ?, + custom_css = ?, + theme_mode = ?, + updated_at = ? + WHERE user_id = ? + """, + ( + str(accent_color).lower(), + normalized_hex_color(name_color) if name_color else None, + custom_css, + theme_mode, + now_iso(), + user["id"], + ), + ) + + with get_db() as db: + row = db.execute( + """ + SELECT + accent_color, + name_color, + custom_css, + theme_mode, + 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)}) + + @app.route("/api/dms") + def api_list_dms(): + user, error = require_user() + if error: + return error + + return jsonify({"dms": list_dms_for_user(get_db, 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(get_db, db_write, now_iso, user["id"], target["id"]) + audit("dm_opened", user["id"], "dm", str(dm_id), str(target["id"])) + + return jsonify({"dm": dm_payload(get_db, dm_id, user["id"])}), 201 + + @app.route("/api/dms//messages") + def api_get_dm_messages(dm_id: int): + user, error = require_user() + if error: + return error + + if not validate_dm_access(get_db, 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(get_db, dm_id, user["id"]), "messages": messages, "latest_id": latest_id, "has_more": len(messages) == MAX_INCREMENTAL_MESSAGES}) + + @app.route("/api/dms//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(get_db, 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(get_db, 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(get_db, 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 + + @app.route("/api/dms//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(get_db, dm_id, user): + return jsonify({"error": "DM not found."}), 404 + + call_id = create_call_session(db_write, now_iso, dm_id, user["id"]) + audit("call_started", user["id"], "call", str(call_id), str(dm_id)) + return jsonify({"call": call_payload(get_db, call_id)}), 201 + + @app.route("/api/calls/") + def api_get_call(call_id: int): + user, error = require_user() + if error: + return error + + call = get_call_with_dm(get_db, call_id) + if not call: + return jsonify({"error": "Call not found."}), 404 + + if not validate_dm_access(get_db, call["direct_conversation_id"], user): + return jsonify({"error": "Call not found."}), 404 + + return jsonify({"call": call_payload(get_db, call_id)}) + + @app.route("/api/calls//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(get_db, call_id) + if not call: + return jsonify({"error": "Call not found."}), 404 + + if not validate_dm_access(get_db, 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(db_write, now_iso, call_id, user["id"], signal_type, payload) + return jsonify({"status": "ok", "signal_id": signal_id}), 201 + + @app.route("/api/calls//signals") + def api_get_call_signals(call_id: int): + user, error = require_user() + if error: + return error + + call = get_call_with_dm(get_db, call_id) + if not call: + return jsonify({"error": "Call not found."}), 404 + + if not validate_dm_access(get_db, 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(get_db, 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//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(get_db, call_id) + if not call: + return jsonify({"error": "Call not found."}), 404 + + if not validate_dm_access(get_db, call["direct_conversation_id"], user): + return jsonify({"error": "Call not found."}), 404 + + end_call(db_write, now_iso, call_id) + audit("call_ended", user["id"], "call", str(call_id)) + return jsonify({"status": "ended", "call": call_payload(get_db, call_id)}) diff --git a/sanitizer.py b/sanitizer.py new file mode 100644 index 0000000..b6a704d --- /dev/null +++ b/sanitizer.py @@ -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"") + + 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) diff --git a/static/js/.env b/static/js/.env new file mode 100644 index 0000000..21df4e2 --- /dev/null +++ b/static/js/.env @@ -0,0 +1,2 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=StrongPass123! diff --git a/static/js/00-config.js b/static/js/00-config.js new file mode 100644 index 0000000..0f63722 --- /dev/null +++ b/static/js/00-config.js @@ -0,0 +1,621 @@ +// static/js/00-config.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.els = { + chatBox: document.getElementById("chatBox"), + dmBox: document.getElementById("dmBox"), + feedBox: document.getElementById("feedBox"), + feedButton: document.getElementById("feedButton"), + dmsButton: document.getElementById("dmsButton"), + composerWrap: document.getElementById("composerWrap"), + chatForm: document.getElementById("chatForm"), + messageInput: document.getElementById("messageInput"), + sendButton: document.getElementById("sendButton"), + messageCount: document.getElementById("messageCount"), + attachmentInput: document.getElementById("attachmentInput"), + attachmentButton: document.getElementById("attachmentButton"), + attachmentName: document.getElementById("attachmentName"), + authOverlay: document.getElementById("authOverlay"), + authNotice: document.getElementById("authNotice"), + usernameInput: document.getElementById("usernameInput"), + passwordInput: document.getElementById("passwordInput"), + loginButton: document.getElementById("loginButton"), + registerButton: document.getElementById("registerButton"), + roomSelect: document.getElementById("roomSelect"), + headerTitle: document.getElementById("headerTitle"), + headerSubtitle: document.getElementById("headerSubtitle"), + headerAccount: document.getElementById("headerAccount"), + presenceDot: document.getElementById("presenceDot"), + presenceLabel: document.getElementById("presenceLabel"), + logoutButton: document.getElementById("logoutButton"), + adminFab: document.getElementById("adminFab"), + adminPanel: document.getElementById("adminPanel"), + closeAdminButton: document.getElementById("closeAdminButton"), + clearMessagesButton: document.getElementById("clearMessagesButton"), + createRoomButton: document.getElementById("createRoomButton"), + typingToggleButton: document.getElementById("typingToggleButton"), + notificationsToggleButton: document.getElementById( + "notificationsToggleButton" + ), + themeButton: document.getElementById("themeButton"), + adminUsers: document.getElementById("adminUsers"), + adminRooms: document.getElementById("adminRooms"), + statusToast: document.getElementById("statusToast"), + blockedOverlay: document.getElementById("blockedOverlay"), + blockedMessage: document.getElementById("blockedMessage"), + newMessagesBar: document.getElementById("newMessagesBar"), + jumpToLatestButton: document.getElementById("jumpToLatestButton"), + typingBar: document.getElementById("typingBar"), + dialogOverlay: document.getElementById("dialogOverlay"), + dialogTitle: document.getElementById("dialogTitle"), + dialogBody: document.getElementById("dialogBody"), + dialogActions: document.getElementById("dialogActions"), + + teamSelect: document.getElementById("teamSelect"), + pagesButton: document.getElementById("pagesButton"), + backButton: document.getElementById("backButton"), + threadBox: document.getElementById("threadBox"), + threadComposerWrap: document.getElementById("threadComposerWrap"), + threadForm: document.getElementById("threadForm"), + threadInput: document.getElementById("threadInput"), + threadSendButton: document.getElementById("threadSendButton"), + threadMessageCount: document.getElementById("threadMessageCount"), + threadAttachmentInput: document.getElementById("threadAttachmentInput"), + threadAttachmentButton: document.getElementById("threadAttachmentButton"), + threadAttachmentName: document.getElementById("threadAttachmentName"), + pagesBox: document.getElementById("pagesBox"), + pageDetailBox: document.getElementById("pageDetailBox"), + + settingsButton: document.getElementById("settingsButton"), + profileBox: document.getElementById("profileBox"), + + headerMenuOverlay: document.getElementById("headerMenuOverlay"), + headerMenuList: document.getElementById("headerMenuList"), + + reactionPopupOverlay: document.getElementById("reactionPopupOverlay"), + reactionPopupGrid: document.getElementById("reactionPopupGrid"), + reactionPopupList: document.getElementById("reactionPopupList"), + reactionCustomInput: document.getElementById("reactionCustomInput"), + reactionCustomButton: document.getElementById("reactionCustomButton"), + closeReactionPopupButton: document.getElementById( + "closeReactionPopupButton" + ), + + mediaOverlay: document.getElementById("mediaOverlay"), + mediaTitle: document.getElementById("mediaTitle"), + mediaImage: document.getElementById("mediaImage"), + mediaAudio: document.getElementById("mediaAudio"), + mediaVideo: document.getElementById("mediaVideo"), + mediaFileFallback: document.getElementById("mediaFileFallback"), + mediaFallbackLink: document.getElementById("mediaFallbackLink"), + mediaDownloadLink: document.getElementById("mediaDownloadLink"), + closeMediaButton: document.getElementById("closeMediaButton"), + + callOverlay: document.getElementById("callOverlay"), + callStatus: document.getElementById("callStatus"), + callPeerLabel: document.getElementById("callPeerLabel"), + startCallButton: document.getElementById("startCallButton"), + answerCallButton: document.getElementById("answerCallButton"), + muteCallButton: document.getElementById("muteCallButton"), + hangupCallButton: document.getElementById("hangupCallButton"), + closeCallPanelButton: document.getElementById("closeCallPanelButton"), + remoteAudio: document.getElementById("remoteAudio") +}; + +Glow.state = { + currentUser: null, + csrfToken: "", + + rooms: [], + activeRoom: null, + + teams: [], + activeTeam: null, + channels: [], + activeChannel: null, + + dms: [], + activeDm: null, + + activeView: "feed", + + lastSeenId: 0, + knownMessageIds: new Set(), + renderedMessages: [], + messageRequestId: 0, + channelRequestId: 0, + dmRequestId: 0, + pollInFlight: false, + channelPollInFlight: false, + dmPollInFlight: false, + + feedLastSeenId: 0, + feedKnownMessageIds: new Set(), + feedEntries: [], + feedRequestId: 0, + feedPollInFlight: false, + + activeThread: null, + threadReplies: [], + threadRequestId: 0, + threadPollInFlight: false, + + pages: [], + activePage: null, + pageTypeFilter: "", + + activeProfile: null, + settings: null, + + pollTimer: null, + initialized: false, + + toastTimer: null, + + selectedAttachment: [], + selectedThreadAttachment: [], + + typingTimer: null, + typingLastSent: 0, + typingUsers: [], + dmTypingUsers: [], + + typingIndicatorsEnabled: + localStorage.getItem("glow_typing_enabled") !== "false", + + notificationsEnabled: + localStorage.getItem("glow_notifications_enabled") === "true", + + accentColor: + localStorage.getItem("glow_accent_color") || "#f7dce8", + + unreadMessages: 0, + originalTitle: document.title, + + dialogResolver: null, + lastFocusedElement: null, + + presence: { + roomOnlineCount: 0, + channelOnlineCount: 0, + dmOnlineCount: 0 + }, + + calls: { + activeCall: null, + peerConnection: null, + localStream: null, + muted: false, + signalLastSeenId: 0, + pollTimer: null, + incomingCall: null, + pendingOffer: null + } +}; + +Glow.MAX_MESSAGE_LENGTH = 1000; +Glow.MAX_ATTACHMENT_BYTES = 5 * 1024 * 1024; +Glow.DEFAULT_ACCENT_COLOR = "#f7dce8"; + +Glow.normalizeAccentColor = value => { + if (typeof value !== "string") { + return null; + } + + const color = value.trim(); + + if (!/^#[0-9a-fA-F]{6}$/.test(color)) { + return null; + } + + return color.toLowerCase(); +}; + +Glow.normalizeNameColor = value => { + if (value == null || value === "") { + return null; + } + + if (typeof value !== "string") { + return null; + } + + const color = value.trim(); + if (!/^#[0-9a-fA-F]{6}$/.test(color)) { + return null; + } + + return color.toLowerCase(); +}; + +Glow.hexToRgb = hex => { + const normalized = Glow.normalizeAccentColor(hex); + + if (!normalized) { + return null; + } + + return { + r: parseInt(normalized.slice(1, 3), 16), + g: parseInt(normalized.slice(3, 5), 16), + b: parseInt(normalized.slice(5, 7), 16) + }; +}; + +Glow.relativeLuminance = hex => { + const rgb = Glow.hexToRgb(hex); + + if (!rgb) { + return 0; + } + + const channel = value => { + const normalized = value / 255; + + return normalized <= 0.03928 + ? normalized / 12.92 + : Math.pow((normalized + 0.055) / 1.055, 2.4); + }; + + return ( + 0.2126 * channel(rgb.r) + + 0.7152 * channel(rgb.g) + + 0.0722 * channel(rgb.b) + ); +}; + +Glow.accentTextColor = hex => { + return Glow.relativeLuminance(hex) >= 0.18 ? hex : "#f2f2f4"; +}; + +Glow.applyAccentColor = value => { + const accent = + Glow.normalizeAccentColor(value) || Glow.DEFAULT_ACCENT_COLOR; + + const rgb = Glow.hexToRgb(accent); + const root = document.documentElement; + + Glow.state.accentColor = accent; + + root.style.setProperty("--accent", accent); + root.style.setProperty("--accent-active", Glow.accentTextColor(accent)); + root.style.setProperty( + "--accent-soft", + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.16)` + ); + root.style.setProperty( + "--accent-hover", + `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.28)` + ); + + localStorage.setItem("glow_accent_color", accent); + + Glow.updateThemeButton?.(); +}; + +Glow.resetAccentColor = () => { + localStorage.removeItem("glow_accent_color"); + Glow.applyAccentColor(Glow.DEFAULT_ACCENT_COLOR); +}; + +Glow.roleDefaultNameColor = role => { + switch (String(role || "").toLowerCase()) { + case "admin": + return "#ff6b6b"; + case "moderator": + return "#7dd3fc"; + default: + return "#f5f5f7"; + } +}; + +Glow.resolveUserNameColor = userish => { + const explicit = + Glow.normalizeNameColor(userish?.name_color) || + Glow.normalizeNameColor(userish?.user_name_color); + + if (explicit) { + return explicit; + } + + return Glow.roleDefaultNameColor(userish?.role || userish?.user_role); +}; + +Glow.applyUserColor = (element, userish) => { + if (!(element instanceof HTMLElement)) { + return; + } + + element.style.color = Glow.resolveUserNameColor(userish); +}; + +Glow.canModerate = () => { + const user = Glow.state.currentUser; + return Boolean(user && ["moderator", "admin"].includes(user.role)); +}; + +Glow.isAdmin = () => { + const user = Glow.state.currentUser; + return Boolean(user && user.role === "admin"); +}; + +Glow.showToast = (message, type = "") => { + clearTimeout(Glow.state.toastTimer); + + Glow.els.statusToast.textContent = String(message); + Glow.els.statusToast.className = `status-toast show ${type}`.trim(); + + Glow.state.toastTimer = setTimeout(() => { + Glow.els.statusToast.className = "status-toast"; + }, 3500); +}; + +Glow.showAuthNotice = (message, type = "error") => { + Glow.els.authNotice.textContent = String(message); + Glow.els.authNotice.className = `notice show ${type}`; +}; + +Glow.clearAuthNotice = () => { + Glow.els.authNotice.textContent = ""; + Glow.els.authNotice.className = "notice"; +}; + +Glow.showBlocked = message => { + Glow.els.blockedMessage.textContent = message || "Access has been blocked."; + + Glow.els.blockedOverlay.classList.remove("hidden"); + Glow.els.authOverlay.classList.add("hidden"); + + Glow.els.messageInput.disabled = true; + Glow.els.sendButton.disabled = true; + Glow.els.roomSelect.disabled = true; + Glow.els.attachmentInput.disabled = true; + + Glow.els.attachmentButton.classList.add("disabled"); + + clearTimeout(Glow.state.pollTimer); +}; + +Glow.relativeTime = iso => { + const seconds = Math.max( + 0, + Math.floor((Date.now() - new Date(iso).getTime()) / 1000) + ); + + if (seconds < 5) { + return "now"; + } + + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + + if (minutes < 60) { + return `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + + if (hours < 24) { + return `${hours}h`; + } + + return `${Math.floor(hours / 24)}d`; +}; + +Glow.formatDateLabel = iso => { + const date = new Date(iso); + const now = new Date(); + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const target = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate() + ); + + const diff = Math.round((today - target) / 86400000); + + if (diff === 0) { + return "Today"; + } + + if (diff === 1) { + return "Yesterday"; + } + + return date.toLocaleDateString(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric" + }); +}; + +Glow.dateKey = iso => { + const date = new Date(iso); + + return [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0") + ].join("-"); +}; + +Glow.isNearBottom = () => { + const activeBox = + Glow.state.activeView === "feed" + ? Glow.els.feedBox + : Glow.state.activeView === "thread" + ? Glow.els.threadBox + : Glow.state.activeView === "dms" + ? Glow.els.dmBox + : Glow.els.chatBox; + + if (!activeBox) { + return true; + } + + return activeBox.scrollHeight - activeBox.scrollTop - activeBox.clientHeight < 100; +}; + +Glow.autoResizeComposer = () => { + const input = Glow.els.messageInput; + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 150)}px`; +}; + +Glow.updateMessageCount = () => { + const count = Glow.els.messageInput.value.length; + Glow.els.messageCount.textContent = `${count} / ${Glow.MAX_MESSAGE_LENGTH}`; +}; + +Glow.updateDocumentTitle = () => { + if (!Glow.state.unreadMessages) { + document.title = Glow.state.originalTitle; + return; + } + + document.title = `(${Glow.state.unreadMessages}) ` + Glow.state.originalTitle; +}; + +Glow.resetUnreadMessages = () => { + Glow.state.unreadMessages = 0; + Glow.updateDocumentTitle(); + Glow.els.newMessagesBar.classList.add("hidden"); +}; + +Glow.showNewMessages = () => { + Glow.els.newMessagesBar.classList.remove("hidden"); +}; + +Glow.normalizeSelectedFiles = fileList => { + if (!fileList) { + return []; + } + + return [...fileList].filter(Boolean); +}; + +Glow.openDialog = ({ + title, + body, + confirmText = "Confirm", + cancelText = "Cancel", + danger = false, + fields = [] +}) => { + if (Glow.state.dialogResolver) { + Glow.closeDialog(null); + } + + Glow.state.lastFocusedElement = document.activeElement; + + Glow.els.dialogTitle.textContent = title; + Glow.els.dialogBody.replaceChildren(); + Glow.els.dialogActions.replaceChildren(); + + if (typeof body === "string") { + const paragraph = document.createElement("p"); + paragraph.textContent = body; + Glow.els.dialogBody.append(paragraph); + } else if (body instanceof Node) { + Glow.els.dialogBody.append(body); + } + + const fieldElements = {}; + + for (const field of fields) { + const label = document.createElement("label"); + label.className = "dialog-label"; + label.textContent = field.label; + + const isMultiline = Boolean(field.multiline); + const input = document.createElement(isMultiline ? "textarea" : "input"); + + input.className = "field"; + input.name = field.name; + input.value = field.value || ""; + input.placeholder = field.placeholder || ""; + + if (isMultiline) { + input.rows = field.rows || 3; + input.maxLength = field.maxLength || 300; + } else { + input.type = field.type || "text"; + input.maxLength = field.maxLength || 300; + + if (field.type === "number") { + input.min = field.min || "1"; + input.max = field.max || "43200"; + input.step = field.step || "1"; + } + } + + label.append(input); + Glow.els.dialogBody.append(label); + fieldElements[field.name] = input; + } + + const cancel = document.createElement("button"); + cancel.className = "button secondary"; + cancel.type = "button"; + cancel.textContent = cancelText; + + const confirmButton = document.createElement("button"); + confirmButton.className = danger ? "danger-button" : "button"; + confirmButton.type = "button"; + confirmButton.textContent = confirmText; + + Glow.els.dialogActions.append(cancel, confirmButton); + Glow.els.dialogOverlay.classList.remove("hidden"); + + return new Promise(resolve => { + Glow.state.dialogResolver = resolve; + + cancel.addEventListener( + "click", + () => Glow.closeDialog(null), + { once: true } + ); + + confirmButton.addEventListener( + "click", + () => { + const values = {}; + + for (const [name, input] of Object.entries(fieldElements)) { + values[name] = input.value.trim(); + } + + Glow.closeDialog(values); + }, + { once: true } + ); + + setTimeout(() => { + const first = Object.values(fieldElements)[0] || confirmButton; + first.focus(); + }, 0); + }); +}; + +Glow.closeDialog = (result = null) => { + Glow.els.dialogOverlay.classList.add("hidden"); + + const resolve = Glow.state.dialogResolver; + Glow.state.dialogResolver = null; + + if (resolve) { + resolve(result); + } + + if (Glow.state.lastFocusedElement instanceof HTMLElement) { + Glow.state.lastFocusedElement.focus(); + } +}; diff --git a/static/js/01-api.js b/static/js/01-api.js new file mode 100644 index 0000000..f3cd213 --- /dev/null +++ b/static/js/01-api.js @@ -0,0 +1,405 @@ +// static/js/01-api.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.refreshCsrfToken = async () => { + const response = await fetch("/api/csrf", { + credentials: "same-origin", + headers: { + Accept: "application/json" + } + }); + + if (!response.ok) { + throw new Error("Could not refresh security token."); + } + + const data = await response.json(); + Glow.state.csrfToken = data.csrf_token || ""; +}; + +Glow.handleUnauthorized = () => { + Glow.state.currentUser = null; + Glow.state.csrfToken = ""; + Glow.state.rooms = []; + Glow.state.activeRoom = null; + Glow.state.teams = []; + Glow.state.activeTeam = null; + Glow.state.channels = []; + Glow.state.activeChannel = null; + Glow.state.dms = []; + Glow.state.activeDm = null; + Glow.state.activeThread = null; + Glow.state.threadReplies = []; + Glow.state.pages = []; + Glow.state.activePage = null; + Glow.state.pageTypeFilter = ""; + Glow.state.activeProfile = null; + Glow.state.settings = null; + Glow.state.selectedAttachment = []; + Glow.state.selectedThreadAttachment = []; + Glow.state.typingUsers = []; + Glow.state.dmTypingUsers = []; + Glow.state.pollInFlight = false; + Glow.state.channelPollInFlight = false; + Glow.state.dmPollInFlight = false; + Glow.state.feedPollInFlight = false; + Glow.state.threadPollInFlight = false; + Glow.state.messageRequestId += 1; + Glow.state.channelRequestId += 1; + Glow.state.dmRequestId += 1; + Glow.state.feedRequestId += 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.typingTimer); + clearTimeout(Glow.state.calls.pollTimer); + + Glow.clearMessages?.(); + Glow.clearFeed?.(); + Glow.clearDms?.(); + Glow.closeHeaderMenu?.(); + Glow.closeReactionPopup?.(); + Glow.closeMediaModal?.(); + Glow.endLocalCallUi?.(); + + Glow.setSelectedAttachment?.([]); + Glow.setSelectedThreadAttachment?.([]); + + Glow.els.threadBox?.replaceChildren(); + Glow.els.pagesBox?.replaceChildren(); + Glow.els.pageDetailBox?.replaceChildren(); + Glow.els.profileBox?.replaceChildren(); + + if (Glow.els.teamSelect) { + Glow.els.teamSelect.replaceChildren(); + Glow.els.teamSelect.disabled = true; + } + + if (Glow.els.roomSelect) { + Glow.els.roomSelect.replaceChildren(); + Glow.els.roomSelect.disabled = true; + } + + if (Glow.els.typingBar) { + Glow.els.typingBar.textContent = ""; + Glow.els.typingBar.classList.add("hidden"); + } + + if (Glow.els.presenceLabel) { + Glow.els.presenceLabel.textContent = ""; + Glow.els.presenceLabel.classList.add("hidden"); + } + + Glow.state.activeView = "feed"; + + Glow.updateAuthUI?.(); + Glow.showAuthNotice?.("Your session has expired. Please sign in again."); + Glow.els.authOverlay.classList.remove("hidden"); +}; + +Glow.apiRequest = async (url, options = {}, retryCsrf = true) => { + const method = (options.method || "GET").toUpperCase(); + const headers = new Headers(options.headers || {}); + headers.set("Accept", "application/json"); + + if (!["GET", "HEAD"].includes(method)) { + headers.set("X-CSRF-Token", Glow.state.csrfToken || ""); + } + + let response; + + try { + response = await fetch(url, { + credentials: "same-origin", + ...options, + method, + headers + }); + } catch (_) { + const error = new Error("Network error. Check your connection and try again."); + error.code = "network"; + throw error; + } + + let data = {}; + + try { + data = await response.json(); + } catch (_) { + data = {}; + } + + if (response.status === 403 && data.code === "blacklisted") { + Glow.showBlocked(data.error); + throw new Error(data.error || "Access blocked."); + } + + if (response.status === 403 && data.code === "csrf" && retryCsrf) { + await Glow.refreshCsrfToken(); + return Glow.apiRequest(url, options, false); + } + + if (response.status === 401) { + Glow.handleUnauthorized(); + } + + if (!response.ok) { + const error = new Error(data.error || "Request failed."); + error.status = response.status; + error.code = data.code; + error.retryAfter = data.retry_after; + error.restrictedUntil = data.restricted_until; + error.data = data; + throw error; + } + + if (data.csrf_token) { + Glow.state.csrfToken = data.csrf_token; + } + + return data; +}; + +/* profile/settings */ +Glow.apiGetMyProfile = () => Glow.apiRequest("/api/profile"); +Glow.apiUpdateMyProfile = payload => + Glow.apiRequest("/api/profile", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiGetPublicProfile = username => + Glow.apiRequest(`/api/profiles/${encodeURIComponent(username)}`); + +Glow.apiGetMySettings = () => Glow.apiRequest("/api/settings"); +Glow.apiUpdateMySettings = payload => + Glow.apiRequest("/api/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +/* teams/channels */ +Glow.apiCreateTeam = payload => + Glow.apiRequest("/api/teams", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiUpdateTeam = (teamSlug, payload) => + Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiCreateChannel = (teamSlug, payload) => + Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/channels`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiUpdateChannel = (teamSlug, channelSlug, payload) => + Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + +Glow.apiDeleteChannel = (teamSlug, channelSlug) => + Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}`, + { method: "DELETE" } + ); + +Glow.apiListTeamMembers = teamSlug => + Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`); + +Glow.apiAddTeamMember = (teamSlug, payload) => + Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiUpdateTeamMember = (teamSlug, userId, payload) => + Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + +Glow.apiRemoveTeamMember = (teamSlug, userId) => + Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/members/${encodeURIComponent(userId)}`, + { method: "DELETE" } + ); + +Glow.apiCreateTeamInvite = (teamSlug, payload = {}) => + Glow.apiRequest(`/api/teams/${encodeURIComponent(teamSlug)}/invites`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiAcceptTeamInvite = token => + Glow.apiRequest(`/api/team-invites/${encodeURIComponent(token)}/accept`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }); + +/* rooms */ +Glow.apiCreateRoom = payload => + Glow.apiRequest("/api/rooms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiUpdateRoom = (roomSlug, payload) => + Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +/* reactions */ +Glow.apiGetMessageReactions = messageId => + Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`); + +Glow.apiAddReaction = (messageId, emoji) => + Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/reactions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ emoji }) + }); + +Glow.apiRemoveReaction = (messageId, emoji) => + Glow.apiRequest( + `/api/messages/${encodeURIComponent(messageId)}/reactions/${encodeURIComponent(emoji)}`, + { method: "DELETE" } + ); + +/* message editing / receipts / search / presence */ +Glow.apiEditMessage = (messageId, payload) => + Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiSearch = query => + Glow.apiRequest(`/api/search?q=${encodeURIComponent(query)}`); + +Glow.apiMarkRead = messageIds => + Glow.apiRequest("/api/read-receipts", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message_ids: messageIds }) + }); + +Glow.apiGetMessageReceipts = messageId => + Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}/receipts`); + +Glow.apiPingPresence = payload => + Glow.apiRequest("/api/presence", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiGetRoomPresence = roomSlug => + Glow.apiRequest(`/api/rooms/${encodeURIComponent(roomSlug)}/presence`); + +Glow.apiGetChannelPresence = (teamSlug, channelSlug) => + Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/channels/${encodeURIComponent(channelSlug)}/presence` + ); + +Glow.apiGetDmPresence = dmId => + Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/presence`); + +Glow.apiLoadHistory = params => { + const qs = new URLSearchParams(params); + return Glow.apiRequest(`/api/messages/history?${qs.toString()}`); +}; + +/* DMs */ +Glow.apiListDms = () => Glow.apiRequest("/api/dms"); + +Glow.apiCreateDm = payload => + Glow.apiRequest("/api/dms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiGetDmMessages = (dmId, afterId = null) => { + const base = `/api/dms/${encodeURIComponent(dmId)}/messages`; + return Glow.apiRequest( + afterId == null ? base : `${base}?after_id=${encodeURIComponent(afterId)}` + ); +}; + +Glow.apiSendDmMessage = (dmId, payload) => + Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/messages`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiGetDmTyping = dmId => + Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`); + +Glow.apiSetDmTyping = (dmId, typing) => + Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/typing`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ typing }) + }); + +/* calls */ +Glow.apiCreateCall = dmId => + Glow.apiRequest(`/api/dms/${encodeURIComponent(dmId)}/calls`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }); + +Glow.apiGetCall = callId => + Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}`); + +Glow.apiPostCallSignal = (callId, payload) => + Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/signal`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + +Glow.apiGetCallSignals = (callId, afterId = 0) => + Glow.apiRequest( + `/api/calls/${encodeURIComponent(callId)}/signals?after_id=${encodeURIComponent(afterId)}` + ); + +Glow.apiEndCall = callId => + Glow.apiRequest(`/api/calls/${encodeURIComponent(callId)}/end`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + }); diff --git a/static/js/02-chat.js b/static/js/02-chat.js new file mode 100644 index 0000000..2282375 --- /dev/null +++ b/static/js/02-chat.js @@ -0,0 +1,1779 @@ +// static/js/02-chat.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.clearMessages = () => { + Glow.state.lastSeenId = 0; + Glow.state.knownMessageIds.clear(); + Glow.state.renderedMessages = []; + Glow.els.chatBox.replaceChildren(); + Glow.resetUnreadMessages(); +}; + +Glow.clearDms = () => { + Glow.state.dms = []; + Glow.state.activeDm = null; + Glow.state.dmTypingUsers = []; + Glow.els.dmBox?.replaceChildren(); +}; + +Glow.getActiveChannel = () => + Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom || null; + +Glow.getCurrentConversationKind = () => { + if (Glow.state.activeDm) { + return "dm"; + } + + if (Glow.state.activeTeam && Glow.state.activeChannel) { + return "channel"; + } + + if (Glow.state.activeRoom) { + return "room"; + } + + return null; +}; + +Glow.getChannelMessageUrl = (channel, afterId = null) => { + if (Glow.state.activeDm) { + const base = `/api/dms/${encodeURIComponent(channel.id)}/messages`; + return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; + } + + const team = Glow.state.activeTeam; + + if (team && Glow.state.activeChannel) { + const base = + `/api/teams/${encodeURIComponent(team.slug)}` + + `/channels/${encodeURIComponent(channel.slug)}/messages`; + + return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; + } + + const base = `/api/rooms/${encodeURIComponent(channel.slug)}/messages`; + return afterId === null ? base : `${base}?after_id=${encodeURIComponent(afterId)}`; +}; + +Glow.getChannelTypingUrl = channel => { + if (Glow.state.activeDm) { + return `/api/dms/${encodeURIComponent(channel.id)}/typing`; + } + + const team = Glow.state.activeTeam; + + if (team && Glow.state.activeChannel) { + return ( + `/api/teams/${encodeURIComponent(team.slug)}` + + `/channels/${encodeURIComponent(channel.slug)}/typing` + ); + } + + return `/api/rooms/${encodeURIComponent(channel.slug)}/typing`; +}; + +Glow.renderEmptyState = (message = "No messages yet.") => { + if (Glow.els.chatBox.children.length) return; + + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = message; + + state.append(card); + Glow.els.chatBox.append(state); +}; + +Glow.renderDmEmptyState = (message = "No direct messages yet.") => { + Glow.els.dmBox.replaceChildren(); + + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = message; + + state.append(card); + Glow.els.dmBox.append(state); +}; + +Glow.renderLoadOlderButton = hasMessages => { + Glow.els.chatBox.querySelector(".load-older-wrap")?.remove(); + if (!hasMessages) return; + + const wrap = document.createElement("div"); + wrap.className = "load-older-wrap"; + + const button = document.createElement("button"); + button.className = "small-button"; + button.type = "button"; + button.textContent = "Load older messages"; + button.addEventListener("click", () => Glow.loadOlderMessages()); + + wrap.append(button); + Glow.els.chatBox.prepend(wrap); +}; + +Glow.canGroupWithPrevious = message => { + const previous = Glow.state.renderedMessages.at(-1); + if (!previous) return false; + if (Number(previous.user_id) !== Number(message.user_id)) return false; + if (Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at)) return false; + + const previousTime = new Date(previous.created_at).getTime(); + const currentTime = new Date(message.created_at).getTime(); + return currentTime - previousTime <= 5 * 60 * 1000; +}; + +Glow.copyMessage = async text => { + try { + await navigator.clipboard.writeText(text); + Glow.showToast("Message copied."); + } catch (_) { + Glow.showToast("Could not copy message.", "error"); + } +}; + +Glow.extractUrls = text => { + if (typeof text !== "string" || !text) return []; + const matches = text.match(/\bhttps?:\/\/[^\s<>"']+/gi) || []; + return [...new Set(matches)]; +}; + +Glow.isImageAttachment = attachment => { + return String(attachment?.mime_type || "").startsWith("image/"); +}; + +Glow.isAudioAttachment = attachment => { + return String(attachment?.mime_type || "").startsWith("audio/"); +}; + +Glow.isVideoAttachment = attachment => { + return String(attachment?.mime_type || "").startsWith("video/"); +}; + +Glow.formatBytes = bytes => { + const value = Number(bytes) || 0; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +}; + +Glow.buildUrlActions = text => { + const urls = Glow.extractUrls(text); + if (!urls.length) return null; + + const row = document.createElement("div"); + row.className = "message-url-actions"; + + for (const url of urls.slice(0, 3)) { + const open = document.createElement("a"); + open.className = "small-button"; + open.href = url; + open.target = "_blank"; + open.rel = "noopener noreferrer"; + open.textContent = "Open link"; + + const copy = document.createElement("button"); + copy.className = "small-button"; + copy.type = "button"; + copy.textContent = "Copy link"; + copy.addEventListener("click", () => Glow.copyMessage(url)); + + row.append(open, copy); + } + + return row; +}; + +Glow.buildProfileLink = (username, message = null) => { + const button = document.createElement("button"); + button.className = "message-author profile-link"; + button.type = "button"; + button.textContent = username; + if (message) { + Glow.applyUserColor(button, message); + } + button.addEventListener("click", () => { + Glow.openProfile?.(username); + }); + return button; +}; + +Glow.renderReactionBar = (message, container) => { + const reactions = Array.isArray(message.reactions) ? message.reactions : []; + if (!reactions.length) return; + + const row = document.createElement("div"); + row.className = "message-reactions"; + + for (const reaction of reactions) { + const button = document.createElement("button"); + button.className = "reaction-chip"; + button.type = "button"; + button.textContent = `${reaction.emoji} ${reaction.count}`; + + if (reaction.reacted) button.classList.add("active"); + + button.addEventListener("click", async () => { + try { + if (reaction.reacted) { + await Glow.apiRemoveReaction(message.id, reaction.emoji); + } else { + await Glow.apiAddReaction(message.id, reaction.emoji); + } + await Glow.refreshMessageReactions?.(message.id); + } catch (error) { + Glow.showToast(error.message || "Could not update reaction.", "error"); + } + }); + + row.append(button); + } + + container.append(row); +}; + +Glow.updateMessageReactionsDom = (messageId, reactions) => { + const row = Glow.els.chatBox.querySelector( + `[data-message-id="${CSS.escape(String(messageId))}"]` + ); + if (!row) return; + + row.querySelector(".message-reactions")?.remove(); + + const bubble = row.querySelector(".message-bubble"); + if (!bubble) return; + + const message = Glow.state.renderedMessages.find( + item => Number(item.id) === Number(messageId) + ); + + if (message) { + message.reactions = reactions || []; + } + + Glow.renderReactionBar({ id: messageId, reactions: reactions || [] }, bubble); +}; + +Glow.refreshMessageReactions = async messageId => { + const data = await Glow.apiGetMessageReactions(messageId); + Glow.updateMessageReactionsDom(messageId, data.reactions || []); +}; + +Glow.canEditMessage = message => { + const currentUser = Glow.state.currentUser; + if (!currentUser) return false; + if (Number(message.user_id) === Number(currentUser.id)) return true; + return Glow.canModerate(); +}; + +Glow.openEditMessageDialog = async message => { + const values = await Glow.openDialog({ + title: "Edit message", + confirmText: "Save changes", + fields: [ + { + name: "text", + label: "Message text", + value: message.text || "", + multiline: true, + rows: 4, + maxLength: Glow.MAX_MESSAGE_LENGTH + } + ] + }); + + if (!values) return; + + try { + const attachmentIds = Array.isArray(message.attachments) + ? message.attachments.map(item => item.id) + : []; + + const data = await Glow.apiEditMessage(message.id, { + text: values.text, + attachment_ids: attachmentIds + }); + + Glow.replaceRenderedMessage(data.message); + Glow.showToast("Message updated."); + } catch (error) { + Glow.showToast(error.message || "Could not edit message.", "error"); + } +}; + +Glow.openReceiptDetails = async message => { + try { + const data = await Glow.apiGetMessageReceipts(message.id); + + const container = document.createElement("div"); + container.className = "member-list"; + + if (!(data.receipts || []).length) { + const empty = document.createElement("div"); + empty.className = "member-row"; + empty.textContent = "No read receipts yet."; + container.append(empty); + } else { + for (const item of data.receipts) { + const row = document.createElement("div"); + row.className = "member-row"; + + const left = document.createElement("span"); + left.textContent = item.username; + + const right = document.createElement("span"); + right.textContent = new Date(item.seen_at).toLocaleString(); + + row.append(left, right); + container.append(row); + } + } + + await Glow.openDialog({ + title: "Read receipts", + body: container, + confirmText: "Close", + cancelText: "Close" + }); + } catch (error) { + Glow.showToast(error.message || "Could not load read receipts.", "error"); + } +}; + +Glow.buildAttachments = attachments => { + const list = document.createElement("div"); + list.className = "message-attachments-list"; + + for (const attachment of attachments || []) { + if (Glow.isImageAttachment(attachment)) { + const imageLink = document.createElement("button"); + imageLink.className = "message-image-link"; + imageLink.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"; + + imageLink.append(image); + imageLink.addEventListener("click", () => { + Glow.openMediaModal?.({ + url: attachment.url, + name: attachment.original_name, + mimeType: attachment.mime_type + }); + }); + + list.append(imageLink); + } else if (Glow.isAudioAttachment(attachment)) { + const audioWrap = document.createElement("div"); + audioWrap.className = "message-audio-wrap"; + + const audio = document.createElement("audio"); + audio.controls = true; + audio.preload = "metadata"; + audio.src = attachment.url; + + const previewButton = document.createElement("button"); + previewButton.className = "small-button"; + previewButton.type = "button"; + previewButton.textContent = "Open player"; + previewButton.addEventListener("click", () => { + Glow.openMediaModal?.({ + url: attachment.url, + name: attachment.original_name, + mimeType: attachment.mime_type + }); + }); + + audioWrap.append(audio, previewButton); + list.append(audioWrap); + } else if (Glow.isVideoAttachment(attachment)) { + const videoWrap = document.createElement("div"); + videoWrap.className = "message-video-wrap"; + + const video = document.createElement("video"); + video.controls = true; + video.preload = "metadata"; + video.src = attachment.url; + video.className = "message-video-preview"; + + const previewButton = document.createElement("button"); + previewButton.className = "small-button"; + previewButton.type = "button"; + previewButton.textContent = "Open video"; + previewButton.addEventListener("click", () => { + Glow.openMediaModal?.({ + url: attachment.url, + name: attachment.original_name, + mimeType: attachment.mime_type + }); + }); + + videoWrap.append(video, previewButton); + list.append(videoWrap); + } + + 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} (${Glow.formatBytes(attachment.size_bytes)})`; + list.append(link); + } + + return list; +}; + +Glow.buildMessage = (message, grouped = false) => { + const row = document.createElement("article"); + const isOwnMessage = + Glow.state.currentUser && + Number(message.user_id) === Number(Glow.state.currentUser.id); + + row.className = + `message-row${isOwnMessage ? " self" : ""}` + + `${grouped ? " grouped" : ""}`; + + row.dataset.messageId = String(message.id); + row.dataset.userId = String(message.user_id); + row.dataset.username = String(message.username || ""); + + if (!grouped) { + const meta = document.createElement("div"); + meta.className = "message-meta"; + + const author = Glow.buildProfileLink(message.username, message); + + const time = document.createElement("time"); + time.className = "message-time"; + time.dateTime = message.created_at; + time.textContent = Glow.relativeTime(message.created_at); + time.title = new Date(message.created_at).toLocaleString(); + + meta.append(author, time); + row.append(meta); + } + + const content = document.createElement("div"); + content.className = "message-content-row"; + + const actions = document.createElement("div"); + actions.className = "message-actions"; + + if (Glow.reactions?.enabled !== false) { + const react = document.createElement("button"); + react.className = "reaction-trigger"; + react.type = "button"; + react.textContent = "😊"; + react.setAttribute("aria-label", "Add reaction"); + react.addEventListener("click", event => { + Glow.openReactionPopup?.(message, event.currentTarget); + }); + actions.append(react); + } + + if (!message.parent_message_id) { + const thread = document.createElement("button"); + thread.className = "message-action"; + thread.type = "button"; + + const replyCount = Number(message.thread_reply_count) || 0; + thread.textContent = + replyCount > 0 + ? `${replyCount} ${replyCount === 1 ? "reply" : "replies"}` + : "Reply"; + + thread.setAttribute("aria-label", "Open message thread"); + thread.addEventListener("click", () => { + Glow.openThread(message.id); + }); + + actions.append(thread); + } + + if (Glow.canEditMessage(message)) { + const edit = document.createElement("button"); + edit.className = "message-action"; + edit.type = "button"; + edit.textContent = "Edit"; + edit.addEventListener("click", () => { + Glow.openEditMessageDialog(message); + }); + actions.append(edit); + } + + const copy = document.createElement("button"); + copy.className = "message-action"; + copy.type = "button"; + copy.textContent = "Copy"; + copy.addEventListener("click", () => { + Glow.copyMessage(message.text); + }); + actions.append(copy); + + const urls = Glow.extractUrls(message.text); + if (urls.length) { + const copyUrl = document.createElement("button"); + copyUrl.className = "message-action"; + copyUrl.type = "button"; + copyUrl.textContent = "URL"; + copyUrl.addEventListener("click", () => { + Glow.copyMessage(urls[0]); + }); + actions.append(copyUrl); + } + + const receipts = document.createElement("button"); + receipts.className = "message-action"; + receipts.type = "button"; + receipts.textContent = `Seen ${Number(message.read_receipt_count) || 0}`; + receipts.addEventListener("click", () => Glow.openReceiptDetails(message)); + actions.append(receipts); + + if (Glow.canModerate()) { + const remove = document.createElement("button"); + remove.className = "admin-delete"; + remove.type = "button"; + remove.textContent = "×"; + remove.setAttribute("aria-label", "Delete message"); + remove.addEventListener("click", () => { + Glow.deleteMessage(message.id); + }); + actions.append(remove); + } + + const bubble = document.createElement("div"); + bubble.className = "message-bubble"; + + const text = document.createElement("div"); + text.textContent = message.text; + bubble.append(text); + + if (message.is_edited) { + const edited = document.createElement("div"); + edited.className = "message-edited"; + edited.textContent = "edited"; + bubble.append(edited); + } + + const urlActions = Glow.buildUrlActions(message.text); + if (urlActions) bubble.append(urlActions); + + const attachments = Array.isArray(message.attachments) + ? message.attachments + : message.attachment + ? [message.attachment] + : []; + + if (attachments.length) { + bubble.append(Glow.buildAttachments(attachments)); + } + + Glow.renderReactionBar(message, bubble); + + content.append(...(isOwnMessage ? [actions, bubble] : [bubble, actions])); + row.append(content); + return row; +}; + +Glow.replaceRenderedMessage = updatedMessage => { + const index = Glow.state.renderedMessages.findIndex( + item => Number(item.id) === Number(updatedMessage.id) + ); + if (index === -1) return; + + Glow.state.renderedMessages[index] = updatedMessage; + + const existing = Glow.els.chatBox.querySelector( + `[data-message-id="${CSS.escape(String(updatedMessage.id))}"]` + ); + if (!existing) return; + + const prev = Glow.state.renderedMessages[index - 1]; + const grouped = !!( + prev && + Number(prev.user_id) === Number(updatedMessage.user_id) && + Glow.dateKey(prev.created_at) === Glow.dateKey(updatedMessage.created_at) && + new Date(updatedMessage.created_at).getTime() - new Date(prev.created_at).getTime() <= + 5 * 60 * 1000 + ); + + existing.replaceWith(Glow.buildMessage(updatedMessage, grouped)); +}; + +Glow.notifyForMessage = message => { + const currentUser = Glow.state.currentUser; + const isOwnMessage = + currentUser && + Number(message.user_id) === Number(currentUser.id); + + if (isOwnMessage) return; + + const isMention = + currentUser && + String(message.text || "") + .toLowerCase() + .includes(`@${currentUser.username.toLowerCase()}`); + + if (!document.hidden && !isMention) return; + + Glow.state.unreadMessages += 1; + Glow.updateDocumentTitle(); + Glow.showNewMessages(); + + if ( + Glow.state.notificationsEnabled && + "Notification" in window && + Notification.permission === "granted" + ) { + let contextName = "glow"; + + if (Glow.state.activeDm) { + const peer = (Glow.state.activeDm.members || []).find( + member => Number(member.id) !== Number(currentUser?.id) + ); + contextName = peer?.display_name || peer?.username || "direct message"; + } else { + contextName = + Glow.state.activeChannel?.name || + Glow.state.activeRoom?.name || + "glow"; + } + + const title = isMention + ? `Mention from ${message.username}` + : `New message in ${contextName}`; + + new Notification(title, { + body: String(message.text || "").slice(0, 180), + tag: `glow-message-${message.id}` + }); + } +}; + +Glow.markVisibleMessagesRead = async () => { + const ids = Glow.state.renderedMessages.map(item => item.id).slice(-100); + if (!ids.length || !Glow.state.currentUser) return; + + try { + await Glow.apiMarkRead(ids); + } catch (_) { + // non-blocking + } +}; + +Glow.appendMessages = (messages, forceScroll = false, notify = false) => { + if (!Array.isArray(messages) || !messages.length) { + if (!Glow.state.knownMessageIds.size) { + Glow.renderEmptyState(); + } + return; + } + + const nearBottom = Glow.isNearBottom(); + const fragment = document.createDocumentFragment(); + + for (const message of messages) { + const id = Number(message.id); + + if (!id || Glow.state.knownMessageIds.has(id)) { + continue; + } + + const previous = Glow.state.renderedMessages.at(-1); + const dateChanged = + !previous || + Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at); + + const grouped = Glow.canGroupWithPrevious(message); + + if (dateChanged) { + const separator = document.createElement("div"); + separator.className = "date-separator"; + separator.dataset.dateKey = Glow.dateKey(message.created_at); + + const text = document.createElement("span"); + text.textContent = Glow.formatDateLabel(message.created_at); + + separator.append(text); + fragment.append(separator); + } + + Glow.state.knownMessageIds.add(id); + Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, id); + Glow.state.renderedMessages.push(message); + + fragment.append(Glow.buildMessage(message, grouped)); + + if (notify) { + Glow.notifyForMessage(message); + } + } + + if (fragment.childNodes.length) { + Glow.els.chatBox.querySelector(".empty-state")?.remove(); + Glow.els.chatBox.append(fragment); + + if (forceScroll || nearBottom) { + Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight; + if (nearBottom) Glow.resetUnreadMessages(); + } + + Glow.markVisibleMessagesRead(); + Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0); + } +}; + +Glow.prependHistoryMessages = messages => { + if (!Array.isArray(messages) || !messages.length) { + Glow.showToast("No older messages found."); + return; + } + + const previousHeight = Glow.els.chatBox.scrollHeight; + Glow.els.chatBox.querySelector(".load-older-wrap")?.remove(); + + const newItems = []; + for (const message of messages) { + const id = Number(message.id); + if (!id || Glow.state.knownMessageIds.has(id)) continue; + newItems.push(message); + } + + Glow.state.renderedMessages = [...newItems, ...Glow.state.renderedMessages]; + Glow.state.knownMessageIds = new Set( + Glow.state.renderedMessages.map(item => Number(item.id)) + ); + + const old = [...Glow.state.renderedMessages]; + Glow.els.chatBox.replaceChildren(); + + const fragment = document.createDocumentFragment(); + let previous = null; + + for (const message of old) { + const dateChanged = + !previous || + Glow.dateKey(previous.created_at) !== Glow.dateKey(message.created_at); + + const grouped = + !!previous && + Number(previous.user_id) === Number(message.user_id) && + Glow.dateKey(previous.created_at) === Glow.dateKey(message.created_at) && + new Date(message.created_at).getTime() - new Date(previous.created_at).getTime() <= + 5 * 60 * 1000; + + if (dateChanged) { + const separator = document.createElement("div"); + separator.className = "date-separator"; + + const label = document.createElement("span"); + label.textContent = Glow.formatDateLabel(message.created_at); + separator.append(label); + fragment.append(separator); + } + + fragment.append(Glow.buildMessage(message, grouped)); + previous = message; + } + + Glow.els.chatBox.append(fragment); + Glow.renderLoadOlderButton(Glow.state.renderedMessages.length > 0); + + const newHeight = Glow.els.chatBox.scrollHeight; + Glow.els.chatBox.scrollTop += newHeight - previousHeight; +}; + +Glow.loadOlderMessages = async () => { + const oldest = Glow.state.renderedMessages[0]; + if (!oldest) return; + + try { + let params; + + if (Glow.state.activeDm) { + params = { + kind: "dm", + dm_id: Glow.state.activeDm.id, + before_id: oldest.id + }; + } else if (Glow.state.activeTeam && Glow.state.activeChannel) { + params = { + kind: "channel", + slug: Glow.state.activeChannel.slug, + team_slug: Glow.state.activeTeam.slug, + before_id: oldest.id + }; + } else { + params = { + kind: "room", + slug: Glow.state.activeRoom.slug, + before_id: oldest.id + }; + } + + const data = await Glow.apiLoadHistory(params); + Glow.prependHistoryMessages(data.messages || []); + } catch (error) { + Glow.showToast(error.message || "Could not load older messages.", "error"); + } +}; + +Glow.loadMessages = async () => { + const room = Glow.state.activeRoom; + if (!room) return; + + const requestId = ++Glow.state.messageRequestId; + Glow.els.chatBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/messages` + ); + + if ( + requestId !== Glow.state.messageRequestId || + Glow.state.activeRoom?.slug !== room.slug + ) { + return; + } + + Glow.clearMessages(); + Glow.appendMessages(data.messages || [], true, false); + Glow.state.lastSeenId = Number(data.latest_id) || 0; + Glow.state.initialized = true; + + if (!(data.messages || []).length) { + Glow.renderEmptyState(); + } + + Glow.renderLoadOlderButton((data.messages || []).length > 0); + } catch (error) { + if (requestId !== Glow.state.messageRequestId) return; + Glow.clearMessages(); + Glow.renderEmptyState(error.message || "Could not load messages."); + Glow.showToast(error.message || "Could not load messages.", "error"); + } finally { + if (requestId === Glow.state.messageRequestId) { + Glow.els.chatBox.setAttribute("aria-busy", "false"); + } + } +}; + +Glow.loadChannelMessages = async () => { + const channel = Glow.getActiveChannel(); + if (!channel) return; + + const requestId = ++Glow.state.channelRequestId; + Glow.els.chatBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiRequest(Glow.getChannelMessageUrl(channel)); + + if ( + requestId !== Glow.state.channelRequestId || + Glow.getActiveChannel()?.slug !== channel.slug + ) { + return; + } + + Glow.clearMessages(); + Glow.state.activeChannel = data.channel || channel; + Glow.state.activeRoom = Glow.state.activeChannel; + + Glow.appendMessages(data.messages || [], true, false); + Glow.state.lastSeenId = Number(data.latest_id) || 0; + Glow.state.initialized = true; + + if (!(data.messages || []).length) { + Glow.renderEmptyState(); + } + + Glow.renderLoadOlderButton((data.messages || []).length > 0); + } catch (error) { + if (requestId !== Glow.state.channelRequestId) return; + Glow.clearMessages(); + Glow.renderEmptyState(error.message || "Could not load channel messages."); + Glow.showToast(error.message || "Could not load channel messages.", "error"); + } finally { + if (requestId === Glow.state.channelRequestId) { + Glow.els.chatBox.setAttribute("aria-busy", "false"); + } + } +}; + +Glow.loadDmMessages = async dm => { + if (!dm) return; + + const requestId = ++Glow.state.dmRequestId; + Glow.els.chatBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiGetDmMessages(dm.id); + + if ( + requestId !== Glow.state.dmRequestId || + Number(Glow.state.activeDm?.id) !== Number(dm.id) + ) { + return; + } + + Glow.clearMessages(); + Glow.state.activeDm = data.dm || dm; + Glow.appendMessages(data.messages || [], true, false); + Glow.state.lastSeenId = Number(data.latest_id) || 0; + Glow.state.initialized = true; + + if (!(data.messages || []).length) { + Glow.renderEmptyState("No direct messages yet."); + } + + Glow.renderLoadOlderButton((data.messages || []).length > 0); + } catch (error) { + if (requestId !== Glow.state.dmRequestId) return; + Glow.clearMessages(); + Glow.renderEmptyState(error.message || "Could not load direct messages."); + Glow.showToast(error.message || "Could not load direct messages.", "error"); + } finally { + if (requestId === Glow.state.dmRequestId) { + Glow.els.chatBox.setAttribute("aria-busy", "false"); + } + } +}; + +Glow.renderDmList = () => { + const box = Glow.els.dmBox; + box.replaceChildren(); + + if (!Glow.state.currentUser) { + Glow.renderDmEmptyState("Sign in to view direct messages."); + return; + } + + const header = document.createElement("div"); + header.className = "pages-header"; + + const copy = document.createElement("div"); + + const title = document.createElement("h2"); + title.textContent = "Direct messages"; + + const subtitle = document.createElement("p"); + subtitle.textContent = "Private conversations with other users."; + + copy.append(title, subtitle); + + const create = document.createElement("button"); + create.className = "button"; + create.type = "button"; + create.textContent = "New DM"; + create.addEventListener("click", () => Glow.openCreateDmDialog()); + + header.append(copy, create); + box.append(header); + + if (!(Glow.state.dms || []).length) { + const empty = document.createElement("div"); + empty.className = "empty-state page-empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = "No direct conversations yet."; + + empty.append(card); + box.append(empty); + return; + } + + const list = document.createElement("div"); + list.className = "dm-list"; + + for (const dm of Glow.state.dms) { + list.append(Glow.buildDmCard(dm)); + } + + box.append(list); +}; + +Glow.buildDmCard = dm => { + const article = document.createElement("article"); + article.className = "page-card dm-card"; + article.tabIndex = 0; + + const peer = + (dm.members || []).find( + member => Number(member.id) !== Number(Glow.state.currentUser?.id) + ) || dm.members?.[0]; + + const title = document.createElement("h3"); + title.textContent = peer?.display_name || peer?.username || "Conversation"; + if (peer) { + Glow.applyUserColor(title, peer); + } + + const subtitle = document.createElement("p"); + subtitle.className = "page-card-excerpt"; + subtitle.textContent = + dm.latest_message?.text || "No messages yet."; + + const footer = document.createElement("div"); + footer.className = "page-card-footer"; + + const left = document.createElement("span"); + left.textContent = peer?.username ? `@${peer.username}` : "DM"; + + const updated = document.createElement("time"); + updated.dateTime = dm.latest_message?.created_at || dm.created_at; + updated.title = new Date(dm.latest_message?.created_at || dm.created_at).toLocaleString(); + updated.textContent = dm.latest_message?.created_at + ? `Updated ${Glow.relativeTime(dm.latest_message.created_at)}` + : `Created ${Glow.relativeTime(dm.created_at)}`; + + footer.append(left, updated); + + const open = async () => { + Glow.state.activeDm = dm; + Glow.state.activeTeam = null; + Glow.state.activeChannel = null; + Glow.state.activeRoom = null; + Glow.state.activeView = "dm"; + Glow.state.typingUsers = []; + Glow.renderTyping(); + Glow.updateViewUI(); + await Glow.loadDmMessages(dm); + Glow.pollTyping(); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + Glow.schedulePoll(); + }; + + article.addEventListener("click", open); + article.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(); + } + }); + + article.append(title, subtitle, footer); + return article; +}; + +Glow.loadDms = async () => { + if (!Glow.state.currentUser) { + Glow.clearDms(); + Glow.renderDmEmptyState("Sign in to view direct messages."); + return; + } + + Glow.els.dmBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiListDms(); + Glow.state.dms = data.dms || []; + Glow.renderDmList(); + } catch (error) { + Glow.renderDmEmptyState(error.message || "Could not load direct messages."); + Glow.showToast(error.message || "Could not load direct messages.", "error"); + } finally { + Glow.els.dmBox.setAttribute("aria-busy", "false"); + } +}; + +Glow.openCreateDmDialog = async () => { + if (!Glow.state.currentUser) { + Glow.showToast("Sign in to start a direct message.", "error"); + return; + } + + const values = await Glow.openDialog({ + title: "Start direct message", + confirmText: "Open DM", + fields: [ + { + name: "username", + label: "Username", + placeholder: "username", + maxLength: 30 + } + ] + }); + + if (!values?.username) return; + + try { + const data = await Glow.apiCreateDm({ + username: values.username.toLowerCase() + }); + + await Glow.loadDms(); + const dm = data.dm; + + if (dm) { + Glow.state.activeDm = dm; + Glow.state.activeTeam = null; + Glow.state.activeChannel = null; + Glow.state.activeRoom = null; + Glow.state.activeView = "dm"; + Glow.updateViewUI(); + await Glow.loadDmMessages(dm); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + Glow.schedulePoll(); + } + } catch (error) { + Glow.showToast(error.message || "Could not start direct message.", "error"); + } +}; + +Glow.pollMessages = async () => { + const channel = Glow.getActiveChannel(); + + if (Glow.state.activeDm) { + return Glow.pollDmMessages(); + } + + if (Glow.state.pollInFlight || !channel || document.hidden) return; + Glow.state.pollInFlight = true; + + try { + let afterId = Glow.state.lastSeenId; + let hasMore = true; + let safety = 0; + + while (hasMore && safety < 20) { + safety += 1; + + const data = await Glow.apiRequest( + Glow.getChannelMessageUrl(channel, afterId) + ); + + if (Glow.getActiveChannel()?.slug !== channel.slug) return; + + Glow.appendMessages(data.messages || [], false, true); + + afterId = Math.max(afterId, Number(data.latest_id) || 0); + Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId); + + hasMore = Boolean(data.has_more) && (data.messages || []).length > 0; + } + } catch (error) { + if ( + error.status !== 401 && + error.code !== "blacklisted" && + error.code !== "network" + ) { + console.error("Message polling failed:", error); + } + } finally { + Glow.state.pollInFlight = false; + } +}; + +Glow.pollDmMessages = async () => { + const dm = Glow.state.activeDm; + + if (Glow.state.dmPollInFlight || !dm || document.hidden) return; + Glow.state.dmPollInFlight = true; + + try { + let afterId = Glow.state.lastSeenId; + let hasMore = true; + let safety = 0; + + while (hasMore && safety < 20) { + safety += 1; + + const data = await Glow.apiGetDmMessages(dm.id, afterId); + + if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return; + + Glow.appendMessages(data.messages || [], false, true); + + afterId = Math.max(afterId, Number(data.latest_id) || 0); + Glow.state.lastSeenId = Math.max(Glow.state.lastSeenId, afterId); + + hasMore = Boolean(data.has_more) && (data.messages || []).length > 0; + } + } catch (error) { + if ( + error.status !== 401 && + error.code !== "blacklisted" && + error.code !== "network" + ) { + console.error("DM polling failed:", error); + } + } finally { + Glow.state.dmPollInFlight = false; + } +}; + +Glow.updateChatControls = () => { + const user = Glow.state.currentUser; + const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom; + const restricted = user && user.status === "restricted"; + + const canSend = Boolean(user && channel && !restricted && channel.can_write !== false); + + Glow.els.messageInput.disabled = !canSend; + Glow.els.sendButton.disabled = !canSend; + Glow.els.attachmentInput.disabled = !canSend; + Glow.els.attachmentButton.classList.toggle("disabled", !canSend); + + if (!user) { + Glow.els.messageInput.placeholder = "sign in to send"; + } else if (restricted) { + Glow.els.messageInput.placeholder = "account temporarily restricted"; + } else if (Glow.state.activeDm) { + Glow.els.messageInput.placeholder = "direct message"; + } else if (channel?.can_write === false) { + Glow.els.messageInput.placeholder = "you cannot post in this channel"; + } else { + Glow.els.messageInput.placeholder = "message"; + } +}; + +Glow.setSelectedAttachment = files => { + Glow.state.selectedAttachment = Array.isArray(files) ? files : []; + Glow.els.attachmentName.textContent = Glow.state.selectedAttachment + .map(file => file.name) + .join(", "); +}; + +Glow.uploadAttachment = async file => { + const form = new FormData(); + form.append("attachment", file); + + return Glow.apiRequest("/api/attachments", { + method: "POST", + body: form + }); +}; + +Glow.uploadSelectedAttachments = async files => { + const uploaded = []; + for (const file of files) { + const result = await Glow.uploadAttachment(file); + if (result?.attachment) uploaded.push(result.attachment); + } + return uploaded; +}; + +Glow.sendTyping = async (isTyping = true) => { + const channel = Glow.getActiveChannel(); + + if ( + !Glow.state.typingIndicatorsEnabled || + !Glow.state.currentUser || + !channel + ) { + return; + } + + const now = Date.now(); + if (isTyping && now - Glow.state.typingLastSent < 1500) return; + + Glow.state.typingLastSent = now; + + try { + if (Glow.state.activeDm) { + await Glow.apiSetDmTyping(channel.id, isTyping); + } else { + await Glow.apiRequest(Glow.getChannelTypingUrl(channel), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ typing: isTyping }) + }); + } + } catch (_) {} +}; + +Glow.pollTyping = async () => { + const channel = Glow.getActiveChannel(); + + if (!channel || !Glow.state.typingIndicatorsEnabled || document.hidden) { + return; + } + + try { + let data; + + if (Glow.state.activeDm) { + data = await Glow.apiGetDmTyping(channel.id); + + if (Number(Glow.state.activeDm?.id) !== Number(channel.id)) { + return; + } + + Glow.state.dmTypingUsers = data.users || []; + Glow.renderTyping(); + return; + } + + data = await Glow.apiRequest(Glow.getChannelTypingUrl(channel)); + + if (Glow.getActiveChannel()?.slug !== channel.slug) { + return; + } + + Glow.state.typingUsers = data.users || []; + Glow.renderTyping(); + } catch (_) {} +}; + +Glow.renderTyping = () => { + const users = Glow.state.activeDm + ? Glow.state.dmTypingUsers || [] + : Glow.state.typingUsers || []; + + if (!users.length || !Glow.state.typingIndicatorsEnabled) { + Glow.els.typingBar.textContent = ""; + Glow.els.typingBar.classList.add("hidden"); + return; + } + + const names = users.map(user => user.username); + + Glow.els.typingBar.textContent = + names.length === 1 + ? `${names[0]} is typing…` + : `${names.slice(0, -1).join(", ")} and ${names.at(-1)} are typing…`; + + Glow.els.typingBar.classList.remove("hidden"); +}; + +Glow.sendMessage = async event => { + event.preventDefault(); + + const team = Glow.state.activeTeam; + const dm = Glow.state.activeDm; + const channel = Glow.getActiveChannel(); + + if (!channel || !Glow.state.currentUser) return; + + const text = Glow.els.messageInput.value.trim(); + const selected = Glow.state.selectedAttachment || []; + + if (!text && !selected.length) return; + + Glow.els.sendButton.disabled = true; + + try { + const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); + const payload = { + text, + attachment_id: uploadedAttachments[0]?.id || null, + attachment_ids: uploadedAttachments.map(item => item.id) + }; + + let data; + + if (dm) { + data = await Glow.apiSendDmMessage(dm.id, payload); + } else if (team && Glow.state.activeChannel) { + try { + data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(team.slug)}` + + `/channels/${encodeURIComponent(channel.slug)}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + } catch (error) { + if (error.status !== 404) throw error; + + data = await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(channel.slug)}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + } + } else { + data = await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(channel.slug)}/messages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + } + + if (dm) { + if (Number(Glow.state.activeDm?.id) !== Number(dm.id)) return; + } else if (Glow.getActiveChannel()?.slug !== channel.slug) { + return; + } + + Glow.els.messageInput.value = ""; + Glow.autoResizeComposer(); + Glow.updateMessageCount(); + Glow.els.attachmentInput.value = ""; + Glow.setSelectedAttachment([]); + Glow.appendMessages([data.message], true, false); + Glow.sendTyping(false); + } catch (error) { + if (error.code === "restricted") { + Glow.showToast( + error.restrictedUntil + ? `Restricted until ${new Date(error.restrictedUntil).toLocaleString()}.` + : "Your account is restricted.", + "error" + ); + } else if (error.status === 429 && error.retryAfter) { + Glow.showToast(`Rate limited. Try again in ${error.retryAfter} seconds.`, "error"); + } else { + Glow.showToast(error.message || "Could not send message.", "error"); + } + } finally { + Glow.updateChatControls(); + if (!Glow.els.messageInput.disabled) { + Glow.els.messageInput.focus(); + } + } +}; + +Glow.deleteMessage = async messageId => { + if (!Glow.canModerate()) return; + + const result = await Glow.openDialog({ + title: "Delete message?", + body: "This message will be permanently removed.", + confirmText: "Delete", + danger: true + }); + + if (!result) return; + + try { + await Glow.apiRequest(`/api/messages/${encodeURIComponent(messageId)}`, { + method: "DELETE" + }); + + Glow.els.chatBox + .querySelector(`[data-message-id="${CSS.escape(String(messageId))}"]`) + ?.remove(); + + Glow.state.knownMessageIds.delete(Number(messageId)); + Glow.state.renderedMessages = Glow.state.renderedMessages.filter( + message => Number(message.id) !== Number(messageId) + ); + + if (!Glow.els.chatBox.querySelector(".message-row")) { + Glow.clearMessages(); + Glow.renderEmptyState(); + } + + Glow.showToast("Message deleted."); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.openThread = async messageId => { + const requestId = ++Glow.state.threadRequestId; + + try { + Glow.state.activeView = "thread"; + Glow.state.activeThread = null; + Glow.state.threadReplies = []; + + Glow.updateViewUI(); + Glow.els.threadBox.setAttribute("aria-busy", "true"); + + const data = await Glow.apiRequest( + `/api/messages/${encodeURIComponent(messageId)}/thread` + ); + + if (requestId !== Glow.state.threadRequestId) return; + + Glow.state.activeThread = data.root; + Glow.state.threadReplies = data.replies || []; + + if (data.dm) { + Glow.state.activeDm = data.dm; + Glow.state.activeTeam = null; + Glow.state.activeChannel = null; + Glow.state.activeRoom = null; + } else { + if (data.team) { + Glow.state.activeTeam = { + ...Glow.state.activeTeam, + slug: data.team.slug, + name: data.team.name + }; + } + + if (data.channel) { + Glow.state.activeChannel = data.channel; + Glow.state.activeRoom = data.channel; + } + } + + Glow.renderThread(); + } catch (error) { + if (requestId !== Glow.state.threadRequestId) return; + Glow.showToast(error.message || "Could not load thread.", "error"); + Glow.returnToChannel(); + } finally { + if (requestId === Glow.state.threadRequestId) { + Glow.els.threadBox.setAttribute("aria-busy", "false"); + } + } +}; + +Glow.returnToChannel = async () => { + Glow.state.activeThread = null; + Glow.state.threadReplies = []; + Glow.state.activeView = Glow.state.activeDm ? "dm" : "channel"; + + Glow.updateViewUI(); + + if (Glow.state.activeDm) { + await Glow.loadDmMessages(Glow.state.activeDm); + } else if (Glow.state.activeChannel) { + await Glow.loadChannelMessages(); + } else { + await Glow.loadMessages(); + } + + Glow.schedulePoll(); +}; + +Glow.buildThreadMessage = (message, root = false) => { + const row = Glow.buildMessage(message, false); + row.classList.add("thread-message"); + if (root) row.classList.add("thread-root"); + return row; +}; + +Glow.renderThread = () => { + const box = Glow.els.threadBox; + const root = Glow.state.activeThread; + + box.replaceChildren(); + + if (!root) { + Glow.renderThreadEmptyState("Thread not found."); + return; + } + + const heading = document.createElement("div"); + heading.className = "thread-heading"; + + const label = document.createElement("div"); + label.className = "thread-label"; + label.textContent = "Thread"; + + const count = document.createElement("div"); + count.className = "thread-count"; + count.textContent = + Glow.state.threadReplies.length === 1 + ? "1 reply" + : `${Glow.state.threadReplies.length} replies`; + + heading.append(label, count); + + box.append(heading); + box.append(Glow.buildThreadMessage(root, true)); + + const separator = document.createElement("div"); + separator.className = "thread-replies-label"; + separator.textContent = + Glow.state.threadReplies.length === 1 + ? "1 reply" + : `${Glow.state.threadReplies.length} replies`; + + box.append(separator); + + if (!Glow.state.threadReplies.length) { + const empty = document.createElement("div"); + empty.className = "thread-empty"; + empty.textContent = "No replies yet. Start the conversation."; + box.append(empty); + } else { + for (const reply of Glow.state.threadReplies) { + box.append(Glow.buildThreadMessage(reply)); + } + } + + box.scrollTop = box.scrollHeight; + Glow.updateThreadControls(); +}; + +Glow.renderThreadEmptyState = message => { + Glow.els.threadBox.replaceChildren(); + + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = message; + + state.append(card); + Glow.els.threadBox.append(state); +}; + +Glow.updateThreadControls = () => { + const user = Glow.state.currentUser; + const thread = Glow.state.activeThread; + const channel = Glow.state.activeDm || Glow.state.activeChannel || Glow.state.activeRoom; + const restricted = user?.status === "restricted"; + + const canReply = Boolean( + user && + thread && + !restricted && + (Glow.state.activeDm || channel?.can_write) + ); + + Glow.els.threadInput.disabled = !canReply; + Glow.els.threadSendButton.disabled = !canReply; + Glow.els.threadAttachmentInput.disabled = !canReply; + Glow.els.threadAttachmentButton.classList.toggle("disabled", !canReply); + + if (!user) { + Glow.els.threadInput.placeholder = "sign in to reply"; + } else if (restricted) { + Glow.els.threadInput.placeholder = "account temporarily restricted"; + } else if (Glow.state.activeDm) { + Glow.els.threadInput.placeholder = "reply in thread"; + } else if (!channel?.can_write) { + Glow.els.threadInput.placeholder = "you cannot reply in this thread"; + } else { + Glow.els.threadInput.placeholder = "reply in thread"; + } +}; + +Glow.setSelectedThreadAttachment = files => { + Glow.state.selectedThreadAttachment = Array.isArray(files) ? files : []; + Glow.els.threadAttachmentName.textContent = + Glow.state.selectedThreadAttachment.map(file => file.name).join(", "); +}; + +Glow.updateThreadMessageCount = () => { + Glow.els.threadMessageCount.textContent = + `${Glow.els.threadInput.value.length} / ${Glow.MAX_MESSAGE_LENGTH}`; +}; + +Glow.autoResizeThreadComposer = () => { + const input = Glow.els.threadInput; + input.style.height = "auto"; + input.style.height = `${Math.min(input.scrollHeight, 150)}px`; +}; + +Glow.sendThreadReply = async event => { + event.preventDefault(); + + const root = Glow.state.activeThread; + if (!root || !Glow.state.currentUser) return; + + const text = Glow.els.threadInput.value.trim(); + const selected = Glow.state.selectedThreadAttachment || []; + + if (!text && !selected.length) return; + + Glow.els.threadSendButton.disabled = true; + + try { + const uploadedAttachments = await Glow.uploadSelectedAttachments(selected); + + const data = await Glow.apiRequest( + `/api/messages/${encodeURIComponent(root.id)}/thread`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text, + attachment_id: uploadedAttachments[0]?.id || null, + attachment_ids: uploadedAttachments.map(item => item.id) + }) + } + ); + + Glow.state.threadReplies.push(data.reply); + + Glow.els.threadInput.value = ""; + Glow.autoResizeThreadComposer(); + Glow.updateThreadMessageCount(); + Glow.els.threadAttachmentInput.value = ""; + Glow.setSelectedThreadAttachment([]); + + Glow.renderThread(); + Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); + } catch (error) { + Glow.showToast(error.message || "Could not send reply.", "error"); + } finally { + Glow.updateThreadControls(); + if (!Glow.els.threadInput.disabled) { + Glow.els.threadInput.focus(); + } + } +}; + +Glow.refreshRootReplyCount = (messageId, count) => { + const row = Glow.els.chatBox.querySelector( + `[data-message-id="${CSS.escape(String(messageId))}"]` + ); + if (!row) return; + + const threadButton = [...row.querySelectorAll(".message-action")].find(button => { + return button.getAttribute("aria-label") === "Open message thread"; + }); + + if (!threadButton) return; + + threadButton.textContent = + count > 0 ? `${count} ${count === 1 ? "reply" : "replies"}` : "Reply"; + + const message = Glow.state.renderedMessages.find( + item => Number(item.id) === Number(messageId) + ); + + if (message) { + message.thread_reply_count = count; + } +}; + +Glow.pollThread = async () => { + const root = Glow.state.activeThread; + + if (Glow.state.threadPollInFlight || !root || document.hidden) return; + Glow.state.threadPollInFlight = true; + + try { + const data = await Glow.apiRequest( + `/api/messages/${encodeURIComponent(root.id)}/thread` + ); + + if (Glow.state.activeThread?.id !== root.id) return; + + Glow.state.activeThread = data.root; + Glow.state.threadReplies = data.replies || []; + Glow.renderThread(); + Glow.refreshRootReplyCount(root.id, Glow.state.threadReplies.length); + } catch (error) { + if (error.status !== 401 && error.status !== 404) { + console.error("Thread polling failed:", error); + } + } finally { + Glow.state.threadPollInFlight = false; + } +}; diff --git a/static/js/03-admin.js b/static/js/03-admin.js new file mode 100644 index 0000000..408e167 --- /dev/null +++ b/static/js/03-admin.js @@ -0,0 +1,689 @@ +// static/js/03-admin.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.closeAdminPanel = () => { + Glow.els.adminPanel.classList.remove("open"); + Glow.els.adminPanel.setAttribute("aria-hidden", "true"); + Glow.els.adminFab.focus(); +}; + +Glow.renderAdminUsers = users => { + Glow.els.adminUsers.replaceChildren(); + + const heading = document.createElement("h3"); + heading.textContent = "Users"; + Glow.els.adminUsers.append(heading); + + if (!users.length) { + const empty = document.createElement("p"); + empty.className = "admin-empty"; + empty.textContent = "No users found."; + Glow.els.adminUsers.append(empty); + return; + } + + 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 = + Glow.state.currentUser && + Number(Glow.state.currentUser.id) === Number(user.id); + + if (!current && Glow.isAdmin()) { + const role = document.createElement("button"); + role.className = "small-button"; + role.type = "button"; + role.textContent = + user.role === "moderator" ? "Make user" : "Make moderator"; + + role.addEventListener("click", () => { + Glow.updateAdminUser(user.id, { + role: user.role === "moderator" ? "user" : "moderator" + }); + }); + + const restrict = document.createElement("button"); + restrict.className = "small-button"; + restrict.type = "button"; + restrict.textContent = + user.status === "restricted" ? "Unrestrict" : "Restrict"; + + restrict.addEventListener("click", () => { + Glow.toggleRestriction(user); + }); + + const blacklist = document.createElement("button"); + blacklist.className = "small-button danger"; + blacklist.type = "button"; + blacklist.textContent = + user.status === "blacklisted" ? "Unblacklist" : "Blacklist"; + + blacklist.addEventListener("click", () => { + Glow.toggleBlacklist(user); + }); + + actions.append(role, restrict, blacklist); + } + + row.append(details, actions); + Glow.els.adminUsers.append(row); + } +}; + +Glow.renderAdminRooms = rooms => { + Glow.els.adminRooms.replaceChildren(); + + const heading = document.createElement("h3"); + heading.textContent = "Rooms"; + Glow.els.adminRooms.append(heading); + + if (!rooms.length) { + const empty = document.createElement("p"); + empty.className = "admin-empty"; + empty.textContent = "No rooms found."; + Glow.els.adminRooms.append(empty); + return; + } + + for (const room of rooms) { + 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()) { + const edit = document.createElement("button"); + edit.className = "small-button"; + edit.type = "button"; + edit.textContent = "Edit"; + edit.addEventListener("click", () => Glow.editRoom(room)); + + actions.append(edit); + + if (room.visibility === "private") { + const members = document.createElement("button"); + members.className = "small-button"; + members.type = "button"; + members.textContent = "Members"; + members.addEventListener("click", () => { + Glow.manageRoomMembers(room); + }); + + const invite = document.createElement("button"); + invite.className = "small-button"; + invite.type = "button"; + invite.textContent = "Invite link"; + invite.addEventListener("click", () => { + Glow.createInviteLink(room); + }); + + actions.append(members, invite); + } + + if (room.slug !== "general") { + const remove = document.createElement("button"); + remove.className = "small-button danger"; + remove.type = "button"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => Glow.deleteRoom(room)); + + actions.append(remove); + } + } + + row.append(details, actions); + Glow.els.adminRooms.append(row); + } +}; + +Glow.openAdminPanel = async () => { + if (!Glow.canModerate()) return; + + Glow.els.adminPanel.classList.add("open"); + Glow.els.adminPanel.setAttribute("aria-hidden", "false"); + + Glow.els.adminUsers.textContent = "Loading users…"; + Glow.els.adminRooms.textContent = "Loading rooms…"; + + try { + const [usersData, roomsData] = await Promise.all([ + Glow.apiRequest("/api/admin/users"), + Glow.apiRequest("/api/admin/rooms") + ]); + + Glow.renderAdminUsers(usersData.users || []); + Glow.renderAdminRooms(roomsData.rooms || []); + Glow.updateAdminPreferenceButtons(); + + setTimeout(() => Glow.els.closeAdminButton.focus(), 0); + } catch (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 => { + if (user.status === "restricted") { + return Glow.updateAdminUser(user.id, { + status: "active", + restricted_until: null + }); + } + + const values = await Glow.openDialog({ + title: `Restrict ${user.username}`, + body: "Enter the restriction duration in minutes.", + confirmText: "Restrict", + danger: true, + fields: [ + { + name: "minutes", + label: "Duration in minutes", + type: "number", + value: "60", + maxLength: 5 + } + ] + }); + + if (!values) return; + + const minutes = Number(values.minutes); + + if (!Number.isFinite(minutes) || minutes < 1 || minutes > 43200) { + Glow.showToast( + "Enter a duration between 1 minute and 30 days.", + "error" + ); + return; + } + + await Glow.updateAdminUser(user.id, { + status: "restricted", + restricted_until: new Date( + Date.now() + minutes * 60 * 1000 + ).toISOString() + }); +}; + +Glow.toggleBlacklist = async user => { + const blacklisting = user.status !== "blacklisted"; + + const result = await Glow.openDialog({ + title: blacklisting + ? `Blacklist ${user.username}?` + : `Unblacklist ${user.username}?`, + body: blacklisting + ? "The account will be denied access immediately." + : "The account will be restored to active status.", + confirmText: blacklisting ? "Blacklist" : "Unblacklist", + danger: blacklisting + }); + + if (!result) return; + + await Glow.updateAdminUser(user.id, { + status: blacklisting ? "blacklisted" : "active" + }); +}; + +Glow.updateAdminUser = async (userId, changes) => { + try { + await Glow.apiRequest(`/api/admin/users/${encodeURIComponent(userId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes) + }); + + Glow.showToast("User updated."); + await Glow.openAdminPanel(); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.createRoom = async () => { + const values = await Glow.openDialog({ + title: "Create room", + body: "Choose a room name, description, and visibility.", + confirmText: "Create", + fields: [ + { + name: "name", + label: "Room name", + placeholder: "Project chat", + maxLength: 60 + }, + { + name: "description", + label: "Description", + placeholder: "Optional room description", + multiline: true, + rows: 3, + maxLength: 300 + }, + { + name: "visibility", + label: "Visibility: public or private", + value: "public", + maxLength: 7 + } + ] + }); + + if (!values) return; + + const visibility = values.visibility.toLowerCase(); + + if (!["public", "private"].includes(visibility)) { + Glow.showToast("Visibility must be public or private.", "error"); + return; + } + + try { + await Glow.apiRequest("/api/rooms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: values.name, + description: values.description, + visibility + }) + }); + + Glow.showToast("Room created."); + await Glow.loadRooms(); + await Glow.openAdminPanel(); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.editRoom = async room => { + const values = await Glow.openDialog({ + title: `Edit ${room.name}`, + confirmText: "Save", + fields: [ + { + name: "name", + label: "Room name", + value: room.name, + maxLength: 60 + }, + { + name: "description", + label: "Description", + value: room.description || "", + multiline: true, + rows: 3, + maxLength: 300 + }, + { + name: "visibility", + label: "Visibility: public or private", + value: room.visibility, + maxLength: 7 + } + ] + }); + + if (!values) return; + + const visibility = values.visibility.toLowerCase(); + + if (!["public", "private"].includes(visibility)) { + Glow.showToast("Visibility must be public or private.", "error"); + return; + } + + await Glow.updateRoom(room.slug, { + name: values.name, + description: values.description, + visibility + }); +}; + +Glow.updateRoom = async (slug, changes) => { + try { + await Glow.apiRequest(`/api/rooms/${encodeURIComponent(slug)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(changes) + }); + + Glow.showToast("Room updated."); + await Glow.loadRooms(); + + if (!Glow.state.activeDm) { + await Glow.loadMessages(); + } + + await Glow.openAdminPanel(); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.deleteRoom = async room => { + const result = await Glow.openDialog({ + title: `Delete ${room.name}?`, + body: "This permanently deletes the room and every message in it.", + confirmText: "Delete room", + danger: true + }); + + if (!result) return; + + try { + await Glow.apiRequest(`/api/rooms/${encodeURIComponent(room.slug)}`, { + method: "DELETE" + }); + + Glow.showToast("Room deleted."); + await Glow.loadRooms(); + + if (!Glow.state.activeDm) { + await Glow.loadMessages(); + } + + await Glow.openAdminPanel(); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.clearActiveRoom = async () => { + const room = Glow.state.activeRoom; + + if (!room || Glow.state.activeDm) return; + + const result = await Glow.openDialog({ + title: `Clear ${room.name}?`, + body: "All messages in this room will be permanently deleted.", + confirmText: "Clear room", + danger: true + }); + + if (!result) return; + + try { + await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/messages`, + { method: "DELETE" } + ); + + Glow.clearMessages(); + Glow.renderEmptyState(); + Glow.showToast("Room messages cleared."); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.manageRoomMembers = async room => { + try { + const data = await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/members` + ); + + const container = document.createElement("div"); + container.className = "member-list"; + + for (const member of data.members || []) { + const row = document.createElement("div"); + row.className = "member-row"; + + const name = document.createElement("span"); + name.textContent = `${member.username} · ${member.membership_role}`; + + const remove = document.createElement("button"); + remove.className = "small-button danger"; + remove.type = "button"; + remove.textContent = "Remove"; + remove.disabled = member.membership_role === "owner"; + + remove.addEventListener("click", async () => { + try { + await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/members/${member.id}`, + { method: "DELETE" } + ); + + Glow.showToast("Member removed."); + Glow.closeDialog({}); + Glow.manageRoomMembers(room); + } catch (error) { + Glow.showToast(error.message, "error"); + } + }); + + row.append(name, remove); + container.append(row); + } + + const values = await Glow.openDialog({ + title: `${room.name} members`, + body: container, + confirmText: "Add member", + fields: [ + { + name: "username", + label: "Username to add", + placeholder: "username", + maxLength: 30 + } + ] + }); + + if (!values?.username) return; + + await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/members`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username: values.username }) + } + ); + + Glow.showToast("Member added."); + await Glow.manageRoomMembers(room); + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; + +Glow.createInviteLink = async room => { + try { + const data = await Glow.apiRequest( + `/api/rooms/${encodeURIComponent(room.slug)}/invites`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + } + ); + + const url = new URL( + data.invite.url, + window.location.origin + ).toString(); + + const values = await Glow.openDialog({ + title: "Private-room invite link", + body: "Copy and share this link. It can be used once before it expires.", + confirmText: "Copy link", + fields: [ + { + name: "invite", + label: "Invite link", + value: url, + maxLength: 500 + } + ] + }); + + if (values) { + await navigator.clipboard.writeText(url); + Glow.showToast("Invite link copied."); + } + } catch (error) { + Glow.showToast(error.message, "error"); + } +}; diff --git a/static/js/04-main.js b/static/js/04-main.js new file mode 100644 index 0000000..adc035a --- /dev/null +++ b/static/js/04-main.js @@ -0,0 +1,859 @@ +// static/js/04-main.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.loadRooms = async () => { + const previousSlug = Glow.state.activeRoom?.slug; + const data = await Glow.apiRequest("/api/rooms"); + + Glow.state.rooms = data.rooms || []; + Glow.els.roomSelect.replaceChildren(); + + for (const room of Glow.state.rooms) { + const option = document.createElement("option"); + option.value = room.slug; + option.textContent = + `${room.name}${room.visibility === "private" ? " (private)" : ""}`; + Glow.els.roomSelect.append(option); + } + + Glow.els.roomSelect.disabled = !Glow.state.rooms.length; + + const selected = + Glow.state.rooms.find(room => room.slug === previousSlug) || + Glow.state.rooms.find(room => room.slug === "general") || + Glow.state.rooms[0] || + null; + + Glow.state.activeRoom = selected; + + Glow.updateViewUI(); + Glow.updateChatControls(); +}; + +Glow.updateViewUI = () => { + const view = Glow.state.activeView || "feed"; + const room = Glow.state.activeRoom; + const dm = Glow.state.activeDm; + + const feedActive = view === "feed"; + const dmListActive = view === "dms"; + const chatActive = view === "room" || view === "channel" || view === "dm"; + const threadActive = view === "thread"; + const pagesActive = view === "pages"; + const pageActive = view === "page"; + const profileActive = view === "profile"; + const settingsActive = view === "settings"; + + Glow.els.feedBox.classList.toggle("hidden", !feedActive); + Glow.els.dmBox?.classList.toggle("hidden", !dmListActive); + Glow.els.chatBox.classList.toggle("hidden", !chatActive); + Glow.els.threadBox?.classList.toggle("hidden", !threadActive); + Glow.els.pagesBox?.classList.toggle("hidden", !pagesActive); + Glow.els.pageDetailBox?.classList.toggle("hidden", !pageActive); + Glow.els.profileBox?.classList.toggle("hidden", !(profileActive || settingsActive)); + + Glow.els.composerWrap.classList.toggle("hidden", !chatActive); + Glow.els.threadComposerWrap?.classList.toggle("hidden", !threadActive); + + if (!chatActive) { + Glow.state.typingUsers = []; + Glow.state.dmTypingUsers = []; + + if (Glow.els.typingBar) { + Glow.els.typingBar.textContent = ""; + Glow.els.typingBar.classList.add("hidden"); + } + } + + Glow.els.feedButton.classList.toggle("active", feedActive); + Glow.els.feedButton.setAttribute("aria-pressed", String(feedActive)); + + Glow.els.pagesButton?.classList.toggle("active", pagesActive || pageActive); + Glow.els.pagesButton?.setAttribute("aria-pressed", String(pagesActive || pageActive)); + + Glow.els.dmsButton?.classList.toggle("active", dmListActive || view === "dm"); + Glow.els.dmsButton?.setAttribute("aria-pressed", String(dmListActive || view === "dm")); + + Glow.els.backButton?.classList.toggle( + "hidden", + !(threadActive || pageActive || profileActive || settingsActive || view === "dm") + ); + + Glow.els.roomSelect.classList.toggle("active", chatActive && !dm); + Glow.els.teamSelect?.classList.toggle("hidden", dmListActive || view === "dm"); + Glow.els.roomSelect?.classList.toggle("hidden", dmListActive || view === "dm"); + + if (feedActive) { + Glow.els.headerTitle.textContent = "Feed"; + Glow.els.headerSubtitle.textContent = Glow.state.currentUser + ? "Recent activity across your accessible rooms" + : "Sign in or create an account to personalize your feed"; + + Glow.els.roomSelect.disabled = !Glow.state.rooms.length; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (dmListActive) { + Glow.els.headerTitle.textContent = "Direct messages"; + Glow.els.headerSubtitle.textContent = Glow.state.currentUser + ? "Private conversations" + : "Sign in to open private conversations"; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (threadActive) { + Glow.els.headerTitle.textContent = "Thread"; + Glow.els.headerSubtitle.textContent = + dm + ? "Direct message replies" + : room?.name || "Conversation replies"; + Glow.updateThreadControls?.(); + return; + } + + if (pagesActive) { + Glow.els.headerTitle.textContent = "Pages"; + Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (pageActive) { + Glow.els.headerTitle.textContent = Glow.state.activePage?.title || "Page"; + Glow.els.headerSubtitle.textContent = "Shared knowledge and resources"; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (profileActive) { + Glow.els.headerTitle.textContent = + Glow.state.activeProfile?.display_name || + Glow.state.activeProfile?.username || + "Profile"; + Glow.els.headerSubtitle.textContent = "User profile"; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (settingsActive) { + Glow.els.headerTitle.textContent = "Settings"; + Glow.els.headerSubtitle.textContent = "Appearance, profile, and preferences"; + Glow.els.presenceLabel?.classList.add("hidden"); + return; + } + + if (view === "dm" && dm) { + const peer = + (dm.members || []).find( + member => Number(member.id) !== Number(Glow.state.currentUser?.id) + ) || dm.members?.[0]; + + Glow.els.headerTitle.textContent = + peer?.display_name || peer?.username || "Direct message"; + Glow.els.headerSubtitle.textContent = + peer?.username ? `@${peer.username}` : "Private conversation"; + Glow.refreshPresence?.(); + Glow.updateChatControls(); + return; + } + + if (room) { + Glow.els.headerTitle.textContent = room.name; + Glow.els.headerSubtitle.textContent = + room.description || + (room.visibility === "private" ? "private room" : "public room"); + + Glow.els.roomSelect.value = room.slug; + } else { + Glow.els.headerTitle.textContent = "No rooms"; + Glow.els.headerSubtitle.textContent = "No accessible chat rooms"; + } + + Glow.updateChatControls(); +}; + +Glow.updateAuthUI = () => { + const user = Glow.state.currentUser; + const authenticated = Boolean(user); + + Glow.els.authOverlay.classList.toggle("hidden", authenticated); + Glow.els.logoutButton.classList.toggle("hidden", !authenticated); + + Glow.els.headerAccount.textContent = + user ? `${user.username} · ${user.role}` : "not signed in"; + + document.body.classList.toggle("is-moderator", Glow.canModerate()); + document.body.classList.toggle("is-admin", Glow.isAdmin()); + + Glow.els.adminFab.classList.toggle("visible", Glow.canModerate()); + + Glow.els.presenceDot.classList.toggle("online", authenticated); + Glow.els.presenceDot.classList.toggle("new", Boolean(user?.status === "restricted")); + + if (user?.status === "restricted") { + Glow.showToast("Your account is temporarily restricted.", "error"); + } + + Glow.updateViewUI(); + Glow.updateChatControls(); + Glow.updateThreadControls?.(); + Glow.renderHeaderMenu?.(); +}; + +Glow.checkInvite = async () => { + const params = new URLSearchParams(window.location.search); + const roomToken = params.get("invite"); + const teamToken = params.get("team_invite"); + + if (roomToken) { + try { + const data = await Glow.apiRequest( + `/api/invites/${encodeURIComponent(roomToken)}/accept`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}) + } + ); + + Glow.showToast(`Joined private room: ${data.room.name}`); + window.history.replaceState({}, "", window.location.pathname); + } catch (error) { + if (error.status !== 401) { + Glow.showToast(error.message, "error"); + window.history.replaceState({}, "", window.location.pathname); + } + } + } + + if (teamToken) { + try { + const data = await Glow.apiAcceptTeamInvite(teamToken); + Glow.showToast(`Joined team: ${data.team.name}`); + window.history.replaceState({}, "", window.location.pathname); + } catch (error) { + if (error.status !== 401) { + Glow.showToast(error.message, "error"); + window.history.replaceState({}, "", window.location.pathname); + } + } + } +}; + +Glow.checkAuth = async () => { + try { + const data = await Glow.apiRequest("/api/me"); + + Glow.state.currentUser = data.authenticated ? data.user : null; + Glow.state.csrfToken = data.csrf_token || ""; + + Glow.updateAuthUI(); + await Glow.checkInvite(); + await Glow.loadRooms(); + await Glow.loadDms?.().catch(() => {}); + + Glow.state.activeView = "feed"; + Glow.updateViewUI(); + await Glow.loadFeed(); + await Glow.loadMySettings?.().catch(() => {}); + } catch (error) { + Glow.showToast(error.message || "Could not connect.", "error"); + } finally { + Glow.schedulePoll(); + } +}; + +Glow.authenticate = async endpoint => { + Glow.clearAuthNotice(); + + const username = Glow.els.usernameInput.value.trim().toLowerCase(); + const password = Glow.els.passwordInput.value; + + if (!username || !password) { + Glow.showAuthNotice("Enter a username and password."); + return; + } + + Glow.els.loginButton.disabled = true; + Glow.els.registerButton.disabled = true; + + try { + const data = await Glow.apiRequest(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }) + }); + + Glow.state.currentUser = data.user; + Glow.state.csrfToken = data.csrf_token || ""; + Glow.els.passwordInput.value = ""; + + Glow.updateAuthUI(); + await Glow.checkInvite(); + await Glow.loadRooms(); + await Glow.loadDms?.().catch(() => {}); + + Glow.state.activeView = "feed"; + Glow.updateViewUI(); + await Glow.loadFeed(); + await Glow.loadMySettings?.().catch(() => {}); + + Glow.showToast( + endpoint.endsWith("register") ? "Account created." : "Signed in." + ); + } catch (error) { + Glow.showAuthNotice( + error.status === 429 && error.retryAfter + ? `Please wait ${error.retryAfter} seconds before trying again.` + : error.message + ); + } finally { + Glow.els.loginButton.disabled = false; + Glow.els.registerButton.disabled = false; + Glow.schedulePoll(); + } +}; + +Glow.logout = async () => { + try { + await Glow.apiRequest("/api/logout", { method: "POST" }); + } catch (_) { + // ignore + } + + Glow.state.currentUser = null; + Glow.state.csrfToken = ""; + Glow.state.rooms = []; + Glow.state.activeRoom = null; + Glow.state.teams = []; + Glow.state.activeTeam = null; + Glow.state.channels = []; + Glow.state.activeChannel = null; + Glow.state.dms = []; + Glow.state.activeDm = null; + Glow.state.activeView = "feed"; + Glow.state.activeThread = null; + Glow.state.threadReplies = []; + Glow.state.activePage = null; + Glow.state.activeProfile = null; + Glow.state.settings = null; + + Glow.clearMessages(); + Glow.clearFeed(); + Glow.clearDms(); + Glow.closeHeaderMenu?.(); + Glow.closeReactionPopup?.(); + Glow.closeMediaModal?.(); + Glow.endLocalCallUi?.(); + + Glow.updateAuthUI(); + + Glow.els.authOverlay.classList.remove("hidden"); + Glow.els.usernameInput.focus(); + + await Glow.refreshCsrfToken().catch(() => {}); + await Glow.loadFeed().catch(() => {}); + + Glow.showToast("Signed out."); +}; + +Glow.toggleNotifications = async () => { + if (!("Notification" in window)) { + Glow.showToast("Browser notifications are not supported.", "error"); + return; + } + + if (!Glow.state.notificationsEnabled) { + const permission = await Notification.requestPermission(); + + if (permission !== "granted") { + Glow.showToast("Notification permission was not granted.", "error"); + return; + } + } + + Glow.state.notificationsEnabled = !Glow.state.notificationsEnabled; + + localStorage.setItem( + "glow_notifications_enabled", + String(Glow.state.notificationsEnabled) + ); + + Glow.updateAdminPreferenceButtons?.(); + Glow.showToast( + Glow.state.notificationsEnabled + ? "Browser notifications enabled." + : "Browser notifications disabled." + ); +}; + +Glow.toggleTypingIndicators = () => { + Glow.state.typingIndicatorsEnabled = !Glow.state.typingIndicatorsEnabled; + + localStorage.setItem( + "glow_typing_enabled", + String(Glow.state.typingIndicatorsEnabled) + ); + + if (!Glow.state.typingIndicatorsEnabled) { + Glow.state.typingUsers = []; + Glow.state.dmTypingUsers = []; + Glow.renderTyping(); + } + + Glow.updateAdminPreferenceButtons?.(); + + Glow.showToast( + Glow.state.typingIndicatorsEnabled + ? "Typing indicators enabled." + : "Typing indicators disabled." + ); +}; + +Glow.pingPresence = async () => { + if (!Glow.state.currentUser) return; + + const payload = { + view: Glow.state.activeView + }; + + if (Glow.state.activeDm?.id) { + payload.direct_conversation_id = Glow.state.activeDm.id; + } else if (Glow.state.activeChannel?.id) { + payload.channel_id = Glow.state.activeChannel.id; + } else if (Glow.state.activeRoom?.id) { + payload.room_id = Glow.state.activeRoom.id; + } + + try { + await Glow.apiPingPresence(payload); + } catch (_) { + // non-blocking + } +}; + +Glow.refreshPresence = async () => { + try { + if (Glow.state.activeDm?.id) { + const data = await Glow.apiGetDmPresence(Glow.state.activeDm.id); + Glow.state.presence.dmOnlineCount = Number(data.online_count) || 0; + Glow.els.presenceLabel.textContent = + `${Glow.state.presence.dmOnlineCount} online`; + Glow.els.presenceLabel.classList.remove("hidden"); + return; + } + + if (Glow.state.activeTeam?.slug && Glow.state.activeChannel?.slug) { + const data = await Glow.apiGetChannelPresence( + Glow.state.activeTeam.slug, + Glow.state.activeChannel.slug + ); + Glow.state.presence.channelOnlineCount = Number(data.online_count) || 0; + Glow.els.presenceLabel.textContent = + `${Glow.state.presence.channelOnlineCount} online`; + Glow.els.presenceLabel.classList.remove("hidden"); + return; + } + + if (Glow.state.activeRoom?.slug) { + const data = await Glow.apiGetRoomPresence(Glow.state.activeRoom.slug); + Glow.state.presence.roomOnlineCount = Number(data.online_count) || 0; + Glow.els.presenceLabel.textContent = + `${Glow.state.presence.roomOnlineCount} online`; + Glow.els.presenceLabel.classList.remove("hidden"); + return; + } + + Glow.els.presenceLabel.classList.add("hidden"); + } catch (_) { + Glow.els.presenceLabel?.classList.add("hidden"); + } +}; + +Glow.schedulePoll = () => { + clearTimeout(Glow.state.pollTimer); + + if (Glow.els.blockedOverlay.classList.contains("hidden") === false) { + return; + } + + Glow.state.pollTimer = setTimeout(async () => { + if (Glow.state.activeView === "feed") { + await Glow.pollFeed(); + } else if ( + Glow.state.activeView === "room" || + Glow.state.activeView === "channel" || + Glow.state.activeView === "dm" + ) { + await Glow.pollMessages(); + await Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "thread") { + await Glow.pollThread?.(); + await Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "dms") { + await Glow.loadDms?.().catch(() => {}); + } + + await Glow.pingPresence?.(); + await Glow.refreshPresence?.(); + + Glow.schedulePoll(); + }, document.hidden ? 12000 : 5000); +}; + +Glow.els.chatForm.addEventListener("submit", Glow.sendMessage); + +Glow.els.threadForm?.addEventListener("submit", event => + Glow.sendThreadReply?.(event) +); + +Glow.els.messageInput.addEventListener("input", () => { + Glow.autoResizeComposer(); + Glow.updateMessageCount(); + + clearTimeout(Glow.state.typingTimer); + + if (Glow.els.messageInput.value.trim()) { + Glow.sendTyping(true); + + Glow.state.typingTimer = setTimeout(() => { + Glow.sendTyping(false); + }, 2500); + } else { + Glow.sendTyping(false); + } +}); + +Glow.els.threadInput?.addEventListener("input", () => { + Glow.autoResizeThreadComposer?.(); + Glow.updateThreadMessageCount?.(); +}); + +Glow.els.messageInput.addEventListener("keydown", event => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + Glow.sendMessage(event); + } +}); + +Glow.els.threadInput?.addEventListener("keydown", event => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + Glow.sendThreadReply?.(event); + } +}); + +Glow.els.attachmentInput.addEventListener("change", () => { + const files = Glow.normalizeSelectedFiles(Glow.els.attachmentInput.files); + + if (!files.length) { + Glow.setSelectedAttachment([]); + return; + } + + const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); + if (invalid) { + Glow.showToast("Attachments must be 5 MB or smaller.", "error"); + Glow.els.attachmentInput.value = ""; + Glow.setSelectedAttachment([]); + return; + } + + Glow.setSelectedAttachment(files); +}); + +Glow.els.threadAttachmentInput?.addEventListener("change", () => { + const files = Glow.normalizeSelectedFiles(Glow.els.threadAttachmentInput.files); + + if (!files.length) { + Glow.setSelectedThreadAttachment?.([]); + return; + } + + const invalid = files.find(file => file.size > Glow.MAX_ATTACHMENT_BYTES); + if (invalid) { + Glow.showToast("Attachments must be 5 MB or smaller.", "error"); + Glow.els.threadAttachmentInput.value = ""; + Glow.setSelectedThreadAttachment?.([]); + return; + } + + Glow.setSelectedThreadAttachment?.(files); +}); + +Glow.els.feedButton.addEventListener("click", Glow.openFeed); + +Glow.els.dmsButton?.addEventListener("click", async () => { + Glow.state.activeView = "dms"; + Glow.updateViewUI(); + await Glow.loadDms?.(); + Glow.schedulePoll(); +}); + +Glow.els.pagesButton?.addEventListener("click", () => { + Glow.openPages?.(); +}); + +Glow.els.backButton?.addEventListener("click", async () => { + if (Glow.state.activeView === "thread") { + await Glow.returnToChannel?.(); + } else if (Glow.state.activeView === "page") { + await Glow.openPages?.(); + } else if (Glow.state.activeView === "profile" || Glow.state.activeView === "settings") { + await Glow.openFeed?.(); + } else if (Glow.state.activeView === "dm") { + Glow.state.activeView = "dms"; + Glow.updateViewUI(); + await Glow.loadDms?.(); + } +}); + +Glow.els.loginButton.addEventListener("click", () => { + Glow.authenticate("/api/login"); +}); + +Glow.els.registerButton.addEventListener("click", () => { + Glow.authenticate("/api/register"); +}); + +Glow.els.passwordInput.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + Glow.authenticate("/api/login"); + } +}); + +Glow.els.logoutButton.addEventListener("click", Glow.logout); + +Glow.els.roomSelect.addEventListener("change", async () => { + const selected = Glow.state.rooms.find( + room => room.slug === Glow.els.roomSelect.value + ); + + if (!selected) return; + + Glow.state.activeDm = null; + Glow.state.activeRoom = selected; + Glow.state.activeView = "room"; + + Glow.state.typingUsers = []; + Glow.state.dmTypingUsers = []; + Glow.renderTyping(); + + Glow.updateViewUI(); + await Glow.loadMessages(); + Glow.pollTyping(); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + Glow.schedulePoll(); +}); + +Glow.els.teamSelect?.addEventListener("change", () => { + const teams = Glow.state.teams || []; + + Glow.state.activeTeam = + teams.find(team => team.slug === Glow.els.teamSelect.value) || null; +}); + +Glow.els.adminFab.addEventListener("click", Glow.openAdminPanel); +Glow.els.closeAdminButton.addEventListener("click", Glow.closeAdminPanel); +Glow.els.createRoomButton.addEventListener("click", Glow.createRoom); +Glow.els.clearMessagesButton.addEventListener("click", Glow.clearActiveRoom); +Glow.els.typingToggleButton.addEventListener("click", Glow.toggleTypingIndicators); +Glow.els.notificationsToggleButton.addEventListener("click", Glow.toggleNotifications); +Glow.els.themeButton?.addEventListener("click", Glow.openAccentColorPicker); + +Glow.els.settingsButton?.addEventListener("click", () => { + Glow.toggleHeaderMenu?.(); +}); + +Glow.els.jumpToLatestButton.addEventListener("click", () => { + Glow.els.chatBox.scrollTop = Glow.els.chatBox.scrollHeight; + Glow.resetUnreadMessages(); +}); + +Glow.els.chatBox.addEventListener("scroll", () => { + if (Glow.isNearBottom()) { + Glow.resetUnreadMessages(); + } +}); + +Glow.els.adminPanel.addEventListener("click", event => { + if (event.target === Glow.els.adminPanel) { + Glow.closeAdminPanel(); + } +}); + +Glow.els.dialogOverlay.addEventListener("click", event => { + if (event.target === Glow.els.dialogOverlay) { + Glow.closeDialog(null); + } +}); + +Glow.els.headerMenuOverlay?.addEventListener("click", event => { + if (event.target === Glow.els.headerMenuOverlay) { + Glow.closeHeaderMenu?.(); + } +}); + +Glow.els.closeReactionPopupButton?.addEventListener("click", () => { + Glow.closeReactionPopup?.(); +}); + +Glow.els.reactionPopupOverlay?.addEventListener("click", event => { + if (event.target === Glow.els.reactionPopupOverlay) { + Glow.closeReactionPopup?.(); + } +}); + +Glow.els.closeMediaButton?.addEventListener("click", () => { + Glow.closeMediaModal?.(); +}); + +Glow.els.mediaOverlay?.addEventListener("click", event => { + if (event.target === Glow.els.mediaOverlay) { + Glow.closeMediaModal?.(); + } +}); + +Glow.els.closeCallPanelButton?.addEventListener("click", () => { + Glow.els.callOverlay?.classList.add("hidden"); +}); + +document.addEventListener("keydown", event => { + if (event.key !== "Escape") return; + + if (!Glow.els.dialogOverlay.classList.contains("hidden")) { + Glow.closeDialog(null); + return; + } + + if (!Glow.els.mediaOverlay?.classList.contains("hidden")) { + Glow.closeMediaModal?.(); + return; + } + + if (!Glow.els.reactionPopupOverlay?.classList.contains("hidden")) { + Glow.closeReactionPopup?.(); + return; + } + + if (!Glow.els.headerMenuOverlay?.classList.contains("hidden")) { + Glow.closeHeaderMenu?.(); + return; + } + + if (!Glow.els.callOverlay?.classList.contains("hidden")) { + Glow.els.callOverlay.classList.add("hidden"); + return; + } + + if (Glow.state.activeView === "thread") { + Glow.returnToChannel?.(); + return; + } + + if (Glow.state.activeView === "page") { + Glow.openPages?.(); + return; + } + + if (Glow.state.activeView === "dm") { + Glow.state.activeView = "dms"; + Glow.updateViewUI(); + Glow.loadDms?.(); + return; + } + + if (Glow.els.adminPanel.classList.contains("open")) { + Glow.closeAdminPanel(); + } +}); + +document.addEventListener("visibilitychange", () => { + Glow.schedulePoll(); + + if (!document.hidden) { + Glow.resetUnreadMessages(); + + if (Glow.state.activeView === "feed") { + Glow.pollFeed(); + } else if ( + Glow.state.activeView === "room" || + Glow.state.activeView === "channel" || + Glow.state.activeView === "dm" + ) { + Glow.pollMessages(); + Glow.pollTyping(); + Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "thread") { + Glow.pollThread?.(); + Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "dms") { + Glow.loadDms?.(); + } + + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + } +}); + +window.addEventListener("online", () => { + Glow.showToast("Back online."); + + if (Glow.state.activeView === "feed") { + Glow.pollFeed(); + } else if ( + Glow.state.activeView === "room" || + Glow.state.activeView === "channel" || + Glow.state.activeView === "dm" + ) { + Glow.pollMessages(); + Glow.pollTyping(); + Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "thread") { + Glow.pollThread?.(); + Glow.pollCallSignals?.(); + } else if (Glow.state.activeView === "dms") { + Glow.loadDms?.(); + } + + Glow.pingPresence?.(); + Glow.refreshPresence?.(); +}); + +window.addEventListener("offline", () => { + Glow.showToast("You are offline.", "error"); +}); + +setInterval(() => { + if ( + Glow.state.activeView === "room" || + Glow.state.activeView === "channel" || + Glow.state.activeView === "dm" + ) { + Glow.pollTyping(); + } +}, 2500); + +setInterval(() => { + if ( + Glow.state.activeView === "room" || + Glow.state.activeView === "channel" || + Glow.state.activeView === "dm" + ) { + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + } +}, 10000); + +Glow.applyAccentColor(Glow.state.accentColor); +Glow.autoResizeComposer(); +Glow.updateMessageCount(); +Glow.autoResizeThreadComposer?.(); +Glow.updateThreadMessageCount?.(); +Glow.checkAuth(); diff --git a/static/js/05-feed.js b/static/js/05-feed.js new file mode 100644 index 0000000..47f0f7e --- /dev/null +++ b/static/js/05-feed.js @@ -0,0 +1,430 @@ +// static/js/05-feed.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.clearFeed = () => { + Glow.state.feedLastSeenId = 0; + Glow.state.feedKnownMessageIds.clear(); + Glow.state.feedEntries = []; + Glow.els.feedBox.replaceChildren(); +}; + +Glow.renderFeedEmptyState = message => { + if (Glow.els.feedBox.children.length) return; + + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = message; + + state.append(card); + Glow.els.feedBox.append(state); +}; + +Glow.openFeedRoom = async slug => { + const room = Glow.state.rooms.find(item => item.slug === slug); + + if (!room) { + Glow.showToast("This room is no longer available.", "error"); + return; + } + + Glow.state.activeDm = null; + Glow.state.activeChannel = null; + Glow.state.activeRoom = room; + Glow.state.activeView = "room"; + + Glow.els.roomSelect.value = room.slug; + + Glow.updateViewUI(); + await Glow.loadMessages(); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + Glow.schedulePoll(); +}; + +Glow.openFeedEntry = async entry => { + if ( + entry.team_slug && + entry.channel_slug && + typeof Glow.loadChannelsForTeam === "function" + ) { + let team = Glow.state.teams?.find(item => item.slug === entry.team_slug); + + if (!team && typeof Glow.loadTeamsForAdapter === "function") { + try { + await Glow.loadTeamsForAdapter(); + team = Glow.state.teams?.find(item => item.slug === entry.team_slug); + } catch (_) { + team = null; + } + } + + if (team) { + Glow.state.activeTeam = team; + Glow.state.activeDm = null; + + if (Glow.els.teamSelect) { + Glow.els.teamSelect.value = team.slug; + } + + try { + await Glow.loadChannelsForTeam(team.slug, entry.channel_slug); + + if (Glow.state.activeChannel) { + Glow.state.activeView = "channel"; + Glow.updateViewUI(); + await Glow.loadChannelMessages?.(); + Glow.pollTyping?.(); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + Glow.schedulePoll(); + return; + } + } catch (error) { + Glow.showToast(error.message || "Could not open this channel.", "error"); + return; + } + } + } + + if (entry.room_slug) { + await Glow.openFeedRoom(entry.room_slug); + return; + } + + Glow.showToast("This conversation is no longer available.", "error"); +}; + +Glow.buildFeedReactions = entry => { + const reactions = Array.isArray(entry.reactions) ? entry.reactions : []; + if (!reactions.length) return null; + + const row = document.createElement("div"); + row.className = "feed-entry-reactions"; + + for (const reaction of reactions) { + const chip = document.createElement("button"); + chip.className = "reaction-chip"; + chip.type = "button"; + chip.textContent = `${reaction.emoji} ${reaction.count}`; + + if (reaction.reacted) { + chip.classList.add("active"); + } + + chip.addEventListener("click", async () => { + try { + if (reaction.reacted) { + await Glow.apiRemoveReaction(entry.id, reaction.emoji); + } else { + await Glow.apiAddReaction(entry.id, reaction.emoji); + } + + await Glow.loadFeed(); + } catch (error) { + Glow.showToast(error.message || "Could not update reaction.", "error"); + } + }); + + row.append(chip); + } + + 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 => { + const card = document.createElement("article"); + card.className = "feed-entry"; + + if (entry.is_mention) { + card.classList.add("mention"); + } + + card.dataset.messageId = String(entry.id); + + if (entry.room_slug) card.dataset.roomSlug = entry.room_slug; + if (entry.team_slug) card.dataset.teamSlug = entry.team_slug; + if (entry.channel_slug) card.dataset.channelSlug = entry.channel_slug; + + const heading = document.createElement("div"); + heading.className = "feed-entry-heading"; + + const room = document.createElement("button"); + room.className = "feed-room-link"; + room.type = "button"; + + if (entry.team_name && entry.channel_name) { + room.textContent = `${entry.team_name} · # ${entry.channel_name}`; + } else { + room.textContent = + `${entry.room_visibility === "private" ? "(private) " : "# "}${entry.room_name || "channel"}`; + } + + room.addEventListener("click", () => { + Glow.openFeedEntry(entry); + }); + + const time = document.createElement("time"); + time.className = "feed-entry-time"; + time.dateTime = entry.created_at; + time.textContent = Glow.relativeTime(entry.created_at); + time.title = new Date(entry.created_at).toLocaleString(); + + heading.append(room, time); + + const author = document.createElement("button"); + author.className = "feed-entry-author profile-link"; + author.type = "button"; + author.textContent = entry.username; + Glow.applyUserColor(author, entry); + author.addEventListener("click", () => { + Glow.openProfile?.(entry.username); + }); + + const body = document.createElement("div"); + body.className = "feed-entry-body"; + body.textContent = entry.text; + + const attachmentBlock = Glow.buildFeedAttachmentBlock(entry); + const reactions = Glow.buildFeedReactions(entry); + + const openButton = document.createElement("button"); + openButton.className = "small-button feed-open-room"; + openButton.type = "button"; + openButton.textContent = "Open channel"; + openButton.addEventListener("click", () => { + Glow.openFeedEntry(entry); + }); + + card.addEventListener("dblclick", () => { + Glow.openFeedEntry(entry); + }); + + card.append(heading, author, body); + + if (attachmentBlock) { + card.append(attachmentBlock); + } + + if (reactions) { + card.append(reactions); + } + + card.append(openButton); + + return card; +}; + +Glow.appendFeedEntries = (entries, forceScroll = false) => { + if (!Array.isArray(entries) || !entries.length) { + if (!Glow.state.feedKnownMessageIds.size) { + Glow.renderFeedEmptyState( + Glow.state.currentUser + ? "No activity in your accessible rooms yet." + : "Sign in or create an account to see your personalized activity feed." + ); + } + return; + } + + const nearBottom = + Glow.els.feedBox.scrollHeight - + Glow.els.feedBox.scrollTop - + Glow.els.feedBox.clientHeight < 100; + + const fragment = document.createDocumentFragment(); + + for (const entry of entries) { + const id = Number(entry.id); + + if (!id || Glow.state.feedKnownMessageIds.has(id)) { + continue; + } + + const previous = Glow.state.feedEntries.at(-1); + + const dateChanged = + !previous || + Glow.dateKey(previous.created_at) !== Glow.dateKey(entry.created_at); + + if (dateChanged) { + const separator = document.createElement("div"); + separator.className = "date-separator"; + separator.dataset.dateKey = Glow.dateKey(entry.created_at); + + const label = document.createElement("span"); + label.textContent = Glow.formatDateLabel(entry.created_at); + + separator.append(label); + fragment.append(separator); + } + + Glow.state.feedKnownMessageIds.add(id); + Glow.state.feedLastSeenId = Math.max(Glow.state.feedLastSeenId, id); + Glow.state.feedEntries.push(entry); + fragment.append(Glow.buildFeedEntry(entry)); + } + + if (!fragment.childNodes.length) return; + + Glow.els.feedBox.querySelector(".empty-state")?.remove(); + Glow.els.feedBox.append(fragment); + + if (forceScroll || nearBottom) { + Glow.els.feedBox.scrollTop = Glow.els.feedBox.scrollHeight; + } +}; + +Glow.loadFeed = async () => { + const requestId = ++Glow.state.feedRequestId; + Glow.els.feedBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiRequest("/api/feed"); + + if (requestId !== Glow.state.feedRequestId || Glow.state.activeView !== "feed") { + return; + } + + Glow.clearFeed(); + Glow.appendFeedEntries(data.entries || [], true); + Glow.state.feedLastSeenId = Number(data.latest_id) || 0; + + if (!(data.entries || []).length) { + Glow.renderFeedEmptyState( + Glow.state.currentUser + ? "No activity in your accessible rooms yet." + : "Sign in or create an account to see your personalized activity feed." + ); + } + } catch (error) { + if (requestId !== Glow.state.feedRequestId) { + return; + } + + Glow.clearFeed(); + Glow.renderFeedEmptyState(error.message || "Could not load activity feed."); + Glow.showToast(error.message || "Could not load activity feed.", "error"); + } finally { + if (requestId === Glow.state.feedRequestId) { + Glow.els.feedBox.setAttribute("aria-busy", "false"); + } + } +}; + +Glow.pollFeed = async () => { + if ( + Glow.state.feedPollInFlight || + Glow.state.activeView !== "feed" || + document.hidden + ) { + return; + } + + Glow.state.feedPollInFlight = true; + + try { + const data = await Glow.apiRequest( + `/api/feed?after_id=${encodeURIComponent(Glow.state.feedLastSeenId)}` + ); + + if (Glow.state.activeView !== "feed") { + return; + } + + Glow.appendFeedEntries(data.entries || [], false); + Glow.state.feedLastSeenId = Math.max( + Glow.state.feedLastSeenId, + Number(data.latest_id) || 0 + ); + } catch (error) { + if (error.status === 404) { + console.warn("Feed API is unavailable. Deploy the /api/feed backend route."); + return; + } + + if ( + error.status !== 401 && + error.code !== "blacklisted" && + error.code !== "network" + ) { + console.error("Feed polling failed:", error); + } + } finally { + Glow.state.feedPollInFlight = false; + } +}; + +Glow.openFeed = async () => { + Glow.state.activeDm = null; + Glow.state.activeView = "feed"; + Glow.updateViewUI(); + await Glow.loadFeed(); + Glow.schedulePoll(); +}; diff --git a/static/js/06-pages.js b/static/js/06-pages.js new file mode 100644 index 0000000..4b37978 --- /dev/null +++ b/static/js/06-pages.js @@ -0,0 +1,465 @@ +// static/js/06-pages.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.renderPagesEmptyState = message => { + Glow.els.pagesBox.replaceChildren(); + + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = message; + + state.append(card); + Glow.els.pagesBox.append(state); +}; + +Glow.pageTypeLabel = type => { + const labels = { + project: "Project", + document: "Document", + update: "Update", + resource: "Resource" + }; + + return labels[type] || "Page"; +}; + +Glow.loadPages = async (pageType = Glow.state.pageTypeFilter) => { + const team = Glow.state.activeTeam; + + if (!team) { + Glow.renderPagesEmptyState("Select a team to view pages."); + return; + } + + Glow.els.pagesBox.setAttribute("aria-busy", "true"); + + try { + const query = pageType ? `?type=${encodeURIComponent(pageType)}` : ""; + + const data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(team.slug)}/pages${query}` + ); + + if (Glow.state.activeTeam?.slug !== team.slug) return; + + Glow.state.pages = data.pages || []; + Glow.state.pageTypeFilter = pageType || ""; + Glow.renderPages(); + } catch (error) { + Glow.renderPagesEmptyState(error.message || "Could not load pages."); + Glow.showToast(error.message || "Could not load pages.", "error"); + } finally { + Glow.els.pagesBox.setAttribute("aria-busy", "false"); + } +}; + +Glow.renderPages = () => { + const box = Glow.els.pagesBox; + const team = Glow.state.activeTeam; + const pages = Glow.state.pages || []; + + box.replaceChildren(); + + const header = document.createElement("div"); + header.className = "pages-header"; + + const copy = document.createElement("div"); + + const title = document.createElement("h2"); + title.textContent = `${team?.name || "Team"} pages`; + + const subtitle = document.createElement("p"); + subtitle.textContent = "Shared team knowledge and public resources."; + + copy.append(title, subtitle); + header.append(copy); + + if (team?.can_manage) { + const create = document.createElement("button"); + create.className = "button"; + create.type = "button"; + create.textContent = "New page"; + create.addEventListener("click", Glow.createPage); + + header.append(create); + } + + box.append(header); + + const filters = document.createElement("div"); + filters.className = "page-filters"; + + const filterOptions = [ + ["", "All"], + ["project", "Projects"], + ["document", "Documents"], + ["update", "Updates"], + ["resource", "Resources"] + ]; + + for (const [value, label] of filterOptions) { + const button = document.createElement("button"); + button.className = "page-filter"; + button.type = "button"; + button.textContent = label; + button.classList.toggle("active", Glow.state.pageTypeFilter === value); + + button.addEventListener("click", () => Glow.loadPages(value)); + filters.append(button); + } + + box.append(filters); + + if (!pages.length) { + const empty = document.createElement("div"); + empty.className = "empty-state page-empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = "No pages match this filter."; + + empty.append(card); + box.append(empty); + return; + } + + const list = document.createElement("div"); + list.className = "pages-list"; + + for (const page of pages) { + list.append(Glow.buildPageCard(page)); + } + + box.append(list); +}; + +Glow.buildPageCard = page => { + const article = document.createElement("article"); + article.className = "page-card"; + article.tabIndex = 0; + + const meta = document.createElement("div"); + meta.className = "page-card-meta"; + + const type = document.createElement("span"); + type.className = "page-badge type"; + type.textContent = Glow.pageTypeLabel(page.page_type); + + const visibility = document.createElement("span"); + visibility.className = `page-badge visibility ${page.visibility}`; + visibility.textContent = page.visibility; + + meta.append(type, visibility); + + const title = document.createElement("h3"); + 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"); + footer.className = "page-card-footer"; + + const context = document.createElement("span"); + context.textContent = page.channel_name ? `#${page.channel_name}` : "Team page"; + + const updated = document.createElement("time"); + updated.dateTime = page.updated_at; + updated.title = new Date(page.updated_at).toLocaleString(); + updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`; + + footer.append(context, updated); + + const open = () => Glow.openPage(page.slug); + + article.addEventListener("click", open); + article.addEventListener("keydown", event => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + open(); + } + }); + + article.append(meta, title, excerpt, footer); + return article; +}; + +Glow.openPages = async () => { + Glow.state.activeDm = null; + Glow.state.activeView = "pages"; + Glow.state.activePage = null; + + Glow.updateViewUI(); + await Glow.loadPages(); +}; + +Glow.openPage = async pageSlug => { + const team = Glow.state.activeTeam; + if (!team) return; + + Glow.state.activeView = "page"; + Glow.state.activePage = null; + Glow.updateViewUI(); + + Glow.els.pageDetailBox.setAttribute("aria-busy", "true"); + + try { + const data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(team.slug)}/pages/${encodeURIComponent(pageSlug)}` + ); + + Glow.state.activePage = data.page; + Glow.renderPageDetail(); + } catch (error) { + Glow.showToast(error.message || "Could not load page.", "error"); + Glow.openPages(); + } finally { + Glow.els.pageDetailBox.setAttribute("aria-busy", "false"); + } +}; + +Glow.renderPageDetail = () => { + const box = Glow.els.pageDetailBox; + const page = Glow.state.activePage; + + box.replaceChildren(); + + if (!page) { + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = "Page not found."; + + state.append(card); + box.append(state); + return; + } + + const header = document.createElement("div"); + header.className = "page-detail-header"; + + const meta = document.createElement("div"); + meta.className = "page-detail-meta"; + + const type = document.createElement("span"); + type.className = "page-badge type"; + type.textContent = Glow.pageTypeLabel(page.page_type); + + const visibility = document.createElement("span"); + visibility.className = `page-badge visibility ${page.visibility}`; + visibility.textContent = page.visibility; + + const updated = document.createElement("time"); + updated.className = "page-updated"; + updated.dateTime = page.updated_at; + updated.title = new Date(page.updated_at).toLocaleString(); + updated.textContent = `Updated ${Glow.relativeTime(page.updated_at)}`; + + meta.append(type, visibility, updated); + + const title = document.createElement("h1"); + title.textContent = page.title; + + const context = document.createElement("p"); + context.className = "page-detail-context"; + context.textContent = page.channel_name + ? `${page.team_name} · #${page.channel_name}` + : page.team_name; + + header.append(meta, title, context); + box.append(header); + + const body = document.createElement("article"); + body.className = "page-body markdown-body"; + body.innerHTML = page.body_html || "

No content yet.

"; + + box.append(body); + + if (page.can_edit) { + const actions = document.createElement("div"); + actions.className = "page-detail-actions"; + + const edit = document.createElement("button"); + edit.className = "button"; + edit.type = "button"; + edit.textContent = "Edit"; + edit.addEventListener("click", () => Glow.editPage(page)); + + const remove = document.createElement("button"); + remove.className = "danger-button"; + remove.type = "button"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => Glow.deletePage(page)); + + actions.append(edit, remove); + box.append(actions); + } +}; + +Glow.pageDialogFields = (page = null) => [ + { + name: "title", + label: "Title", + value: page?.title || "", + placeholder: "Page title", + maxLength: 160 + }, + { + name: "page_type", + label: "Type: project, document, update, or resource", + value: page?.page_type || "document", + maxLength: 12 + }, + { + name: "visibility", + label: "Visibility: team or public", + value: page?.visibility || "team", + maxLength: 6 + }, + { + name: "channel_slug", + label: "Channel slug (optional)", + value: page?.channel_slug || "", + placeholder: "general", + maxLength: 48 + }, + { + name: "body", + label: "Markdown content", + value: page?.body || "", + multiline: true, + rows: 14, + maxLength: 100000 + } +]; + +Glow.validatePageValues = values => { + if (!values.title) { + Glow.showToast("Page title is required.", "error"); + return null; + } + + const pageType = values.page_type.toLowerCase(); + if (!["project", "document", "update", "resource"].includes(pageType)) { + Glow.showToast("Choose a valid page type.", "error"); + return null; + } + + const visibility = values.visibility.toLowerCase(); + if (!["team", "public"].includes(visibility)) { + Glow.showToast("Visibility must be team or public.", "error"); + return null; + } + + return { + title: values.title, + body: values.body, + page_type: pageType, + visibility, + channel_slug: values.channel_slug.toLowerCase() + }; +}; + +Glow.createPage = async () => { + const team = Glow.state.activeTeam; + if (!team?.can_manage) return; + + const values = await Glow.openDialog({ + title: "Create page", + body: "Create a shared team page or a public resource. Markdown is supported.", + confirmText: "Create page", + fields: Glow.pageDialogFields() + }); + + if (!values) return; + + const payload = Glow.validatePageValues(values); + if (!payload) return; + + try { + const data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(team.slug)}/pages`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + + Glow.showToast("Page created."); + await Glow.openPage(data.page.slug); + } catch (error) { + Glow.showToast(error.message || "Could not create page.", "error"); + } +}; + +Glow.editPage = async page => { + if (!page.can_edit) return; + + const values = await Glow.openDialog({ + title: `Edit ${page.title}`, + confirmText: "Save", + fields: Glow.pageDialogFields(page) + }); + + if (!values) return; + + const payload = Glow.validatePageValues(values); + if (!payload) return; + + try { + const data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + } + ); + + Glow.state.activePage = data.page; + Glow.renderPageDetail(); + Glow.showToast("Page updated."); + } catch (error) { + Glow.showToast(error.message || "Could not update page.", "error"); + } +}; + +Glow.deletePage = async page => { + if (!page.can_edit) return; + + const result = await Glow.openDialog({ + title: `Delete ${page.title}?`, + body: "This page will no longer be available.", + confirmText: "Delete page", + danger: true + }); + + if (!result) return; + + try { + await Glow.apiRequest( + `/api/teams/${encodeURIComponent(page.team_slug)}/pages/${encodeURIComponent(page.slug)}`, + { method: "DELETE" } + ); + + Glow.showToast("Page deleted."); + await Glow.openPages(); + } catch (error) { + Glow.showToast(error.message || "Could not delete page.", "error"); + } +}; diff --git a/static/js/07-team-channel-adapter.js b/static/js/07-team-channel-adapter.js new file mode 100644 index 0000000..2963c73 --- /dev/null +++ b/static/js/07-team-channel-adapter.js @@ -0,0 +1,681 @@ +// static/js/07-team-channel-adapter.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.teamAdapter = Glow.teamAdapter || { + initialized: false, + originalLoadRooms: null, + originalUpdateViewUI: null, + chatBarVisibilityInstalled: false +}; + +Glow.loadTeamsForAdapter = async () => { + if (!Glow.els.teamSelect) return; + + try { + const data = await Glow.apiRequest("/api/teams"); + const teams = data.teams || []; + + const selectedSlug = + Glow.state.activeTeam?.slug || + Glow.els.teamSelect.value; + + Glow.state.teams = teams; + Glow.els.teamSelect.replaceChildren(); + + for (const team of teams) { + const option = document.createElement("option"); + option.value = team.slug; + option.textContent = team.name; + Glow.els.teamSelect.append(option); + } + + Glow.state.activeTeam = + teams.find(team => team.slug === selectedSlug) || + teams.find(team => team.slug === "general") || + teams[0] || + null; + + Glow.els.teamSelect.disabled = !Glow.state.activeTeam; + + if (Glow.state.activeTeam) { + Glow.els.teamSelect.value = Glow.state.activeTeam.slug; + } + } catch (error) { + console.warn("Team adapter could not load teams:", error); + Glow.state.teams = []; + Glow.state.activeTeam = null; + Glow.els.teamSelect.disabled = true; + } +}; + +Glow.loadChannelsForTeam = async (teamSlug, preferredChannelSlug = null) => { + if (!Glow.els.roomSelect) return; + + if (!teamSlug) { + Glow.state.channels = []; + Glow.state.activeChannel = null; + Glow.els.roomSelect.replaceChildren(); + Glow.els.roomSelect.disabled = true; + return; + } + + const data = await Glow.apiRequest( + `/api/teams/${encodeURIComponent(teamSlug)}/channels` + ); + + const channels = data.channels || []; + Glow.state.channels = channels; + + const matchingTeam = Glow.state.teams.find(team => team.slug === teamSlug); + + if (matchingTeam || data.team) { + Glow.state.activeTeam = { + ...(matchingTeam || {}), + ...(data.team || {}) + }; + } + + Glow.els.roomSelect.replaceChildren(); + + for (const channel of channels) { + const option = document.createElement("option"); + option.value = channel.slug; + option.textContent = `# ${channel.name}`; + Glow.els.roomSelect.append(option); + } + + const selected = + channels.find(channel => channel.slug === preferredChannelSlug) || + 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[0] || + null; + + Glow.state.activeChannel = selected; + Glow.state.activeRoom = selected; + + Glow.els.roomSelect.disabled = !selected; + + if (selected) { + Glow.els.roomSelect.value = selected.slug; + } +}; + +Glow.decorateChannelSelector = () => { + if (!Glow.els.roomSelect) return; + + Glow.els.roomSelect.classList.add("channel-select"); + Glow.els.roomSelect.setAttribute("aria-label", "Select channel"); +}; + +Glow.updateTeamChannelHeader = () => { + const team = Glow.state.activeTeam; + const channel = Glow.state.activeChannel || Glow.state.activeRoom; + + const isChannelView = + Glow.state.activeView === "channel" || + Glow.state.activeView === "room"; + + if ( + Glow.state.activeView === "dm" || + Glow.state.activeView === "dms" + ) { + return; + } + + if (!isChannelView || !team || !channel || !Glow.els.headerTitle) { + return; + } + + Glow.els.headerTitle.textContent = `# ${channel.name}`; + Glow.els.headerSubtitle.textContent = + `${team.name} · ${channel.description || "Channel conversation"}`; +}; + +Glow.applyComposerVisibility = () => { + const view = Glow.state.activeView || "feed"; + + const showChannelComposer = + view === "room" || view === "channel" || view === "dm"; + const showThreadComposer = view === "thread"; + + Glow.els.composerWrap?.classList.toggle("hidden", !showChannelComposer); + Glow.els.threadComposerWrap?.classList.toggle("hidden", !showThreadComposer); + + if (!showChannelComposer) { + Glow.state.typingUsers = []; + Glow.state.dmTypingUsers = []; + Glow.renderTyping?.(); + } +}; + +Glow.openCreateTeamDialog = async () => { + const values = await Glow.openDialog({ + title: "Create team", + body: "Create a team. Each user can own up to 3 teams.", + confirmText: "Create team", + fields: [ + { + name: "name", + label: "Team name", + placeholder: "Design", + maxLength: 60 + }, + { + name: "description", + label: "Description", + placeholder: "Optional team description", + multiline: true, + rows: 3, + maxLength: 300 + }, + { + name: "slug", + label: "Slug (optional)", + placeholder: "design", + maxLength: 48 + } + ] + }); + + if (!values) return; + + try { + const data = await Glow.apiCreateTeam({ + name: values.name, + description: values.description, + slug: values.slug + }); + + Glow.showToast("Team created."); + await Glow.refreshTeamAndChannelData(data.team?.slug || null); + } catch (error) { + Glow.showToast(error.message || "Could not create team.", "error"); + } +}; + +Glow.openEditTeamDialog = async team => { + if (!team?.can_manage) { + Glow.showToast("You cannot edit this team.", "error"); + return; + } + + const values = await Glow.openDialog({ + title: `Edit ${team.name}`, + confirmText: "Save", + fields: [ + { + name: "name", + label: "Team name", + value: team.name || "", + maxLength: 60 + }, + { + name: "description", + label: "Description", + value: team.description || "", + multiline: true, + rows: 3, + maxLength: 300 + } + ] + }); + + if (!values) return; + + try { + await Glow.apiUpdateTeam(team.slug, { + name: values.name, + description: values.description + }); + + Glow.showToast("Team updated."); + await Glow.refreshTeamAndChannelData(team.slug); + } catch (error) { + Glow.showToast(error.message || "Could not update team.", "error"); + } +}; + +Glow.openCreateChannelDialog = async team => { + if (!team?.can_manage) { + Glow.showToast("You cannot add channels to this team.", "error"); + return; + } + + const values = await Glow.openDialog({ + title: `Add channel to ${team.name}`, + confirmText: "Create channel", + fields: [ + { + name: "name", + label: "Channel name", + placeholder: "announcements", + maxLength: 60 + }, + { + name: "description", + label: "Description", + placeholder: "Optional channel description", + multiline: true, + rows: 3, + maxLength: 300 + }, + { + name: "slug", + label: "Slug (optional)", + placeholder: "announcements", + maxLength: 48 + } + ] + }); + + if (!values) return; + + try { + const data = await Glow.apiCreateChannel(team.slug, { + name: values.name, + description: values.description, + slug: values.slug + }); + + Glow.showToast("Channel created."); + await Glow.refreshTeamAndChannelData(team.slug, data.channel?.slug || null); + } catch (error) { + Glow.showToast(error.message || "Could not create channel.", "error"); + } +}; + +Glow.openEditChannelDialog = async (team, channel) => { + if (!team?.can_manage || !channel) { + Glow.showToast("You cannot edit this channel.", "error"); + return; + } + + const values = await Glow.openDialog({ + title: `Edit # ${channel.name}`, + confirmText: "Save", + fields: [ + { + name: "name", + label: "Channel name", + value: channel.name || "", + maxLength: 60 + }, + { + name: "description", + label: "Description", + value: channel.description || "", + multiline: true, + rows: 3, + maxLength: 300 + } + ] + }); + + if (!values) return; + + try { + await Glow.apiUpdateChannel(team.slug, channel.slug, { + name: values.name, + description: values.description + }); + + Glow.showToast("Channel updated."); + await Glow.refreshTeamAndChannelData(team.slug, channel.slug); + } catch (error) { + Glow.showToast(error.message || "Could not update channel.", "error"); + } +}; + +Glow.manageTeamMembers = async team => { + if (!team?.can_manage) { + Glow.showToast("You cannot manage this team.", "error"); + return; + } + + try { + const data = await Glow.apiListTeamMembers(team.slug); + + const container = document.createElement("div"); + container.className = "member-list"; + + for (const member of data.members || []) { + const row = document.createElement("div"); + row.className = "member-row"; + + const name = document.createElement("span"); + name.textContent = `${member.username} · ${member.membership_role}`; + + const actions = document.createElement("div"); + actions.className = "settings-actions"; + + const promote = document.createElement("button"); + promote.className = "small-button"; + promote.type = "button"; + promote.textContent = + member.membership_role === "member" ? "Make admin" : "Make member"; + promote.disabled = member.membership_role === "owner"; + promote.addEventListener("click", async () => { + try { + await Glow.apiUpdateTeamMember(team.slug, member.id, { + membership_role: + member.membership_role === "member" ? "admin" : "member" + }); + Glow.closeDialog({}); + Glow.manageTeamMembers(team); + } catch (error) { + Glow.showToast(error.message || "Could not update member.", "error"); + } + }); + + const remove = document.createElement("button"); + remove.className = "small-button danger"; + remove.type = "button"; + remove.textContent = "Remove"; + remove.disabled = member.membership_role === "owner"; + remove.addEventListener("click", async () => { + try { + await Glow.apiRemoveTeamMember(team.slug, member.id); + Glow.closeDialog({}); + Glow.manageTeamMembers(team); + } catch (error) { + Glow.showToast(error.message || "Could not remove member.", "error"); + } + }); + + actions.append(promote, remove); + row.append(name, actions); + container.append(row); + } + + const values = await Glow.openDialog({ + title: `${team.name} members`, + body: container, + confirmText: "Add member", + fields: [ + { + name: "username", + label: "Username to add", + placeholder: "username", + maxLength: 30 + }, + { + name: "membership_role", + label: "Role: member or admin", + value: "member", + maxLength: 6 + } + ] + }); + + if (!values?.username) return; + + await Glow.apiAddTeamMember(team.slug, { + username: values.username, + membership_role: values.membership_role || "member" + }); + + Glow.showToast("Member added."); + await Glow.manageTeamMembers(team); + } catch (error) { + Glow.showToast(error.message || "Could not manage team members.", "error"); + } +}; + +Glow.createTeamInviteLink = async team => { + if (!team?.can_manage) return; + + try { + const values = await Glow.openDialog({ + title: `Create invite for ${team.name}`, + confirmText: "Create link", + fields: [ + { + name: "membership_role", + label: "Invite role: member or admin", + value: "member", + maxLength: 6 + } + ] + }); + + if (!values) return; + + const data = await Glow.apiCreateTeamInvite(team.slug, { + membership_role: values.membership_role || "member" + }); + + const url = new URL(data.invite.url, window.location.origin).toString(); + + const result = await Glow.openDialog({ + title: "Team invite link", + body: "Copy and share this link. It can be used once before it expires.", + confirmText: "Copy link", + fields: [ + { + name: "invite", + label: "Invite link", + value: url, + maxLength: 500 + } + ] + }); + + if (result) { + await navigator.clipboard.writeText(url); + Glow.showToast("Invite link copied."); + } + } catch (error) { + Glow.showToast(error.message || "Could not create team invite.", "error"); + } +}; + +Glow.deleteCurrentChannel = async team => { + const channel = Glow.state.activeChannel; + if (!team?.can_manage || !channel) return; + + const result = await Glow.openDialog({ + title: `Delete # ${channel.name}?`, + body: "This channel will no longer be available.", + confirmText: "Delete channel", + danger: true + }); + + if (!result) return; + + try { + await Glow.apiDeleteChannel(team.slug, channel.slug); + Glow.showToast("Channel deleted."); + await Glow.refreshTeamAndChannelData(team.slug); + } catch (error) { + Glow.showToast(error.message || "Could not delete channel.", "error"); + } +}; + +Glow.refreshTeamAndChannelData = async (teamSlug = null, channelSlug = null) => { + await Glow.loadTeamsForAdapter(); + + const targetTeamSlug = + teamSlug || + Glow.state.activeTeam?.slug || + Glow.state.teams?.[0]?.slug || + null; + + if (!targetTeamSlug) { + Glow.updateViewUI(); + return; + } + + await Glow.loadChannelsForTeam(targetTeamSlug, channelSlug); + + Glow.state.activeDm = null; + Glow.state.activeView = "channel"; + Glow.updateViewUI(); + + if (Glow.state.activeChannel) { + await Glow.loadChannelMessages?.(); + Glow.pollTyping?.(); + Glow.pingPresence?.(); + Glow.refreshPresence?.(); + } else { + Glow.clearMessages?.(); + Glow.renderEmptyState?.("No channels are available in this team."); + } + + Glow.schedulePoll?.(); +}; + +Glow.installTeamAdapter = () => { + 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.state.teams ||= []; + Glow.state.channels ||= []; + Glow.state.activeTeam ||= null; + Glow.state.activeChannel ||= null; + + Glow.decorateChannelSelector(); + + 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(); + +if (!Glow.teamAdapter.chatBarVisibilityInstalled) { + Glow.teamAdapter.chatBarVisibilityInstalled = true; + + const previousUpdateViewUI = Glow.updateViewUI; + + Glow.updateViewUI = () => { + previousUpdateViewUI?.(); + Glow.applyComposerVisibility(); + }; +} + +Glow.applyComposerVisibility(); diff --git a/static/js/08-settings.js b/static/js/08-settings.js new file mode 100644 index 0000000..e6e5c70 --- /dev/null +++ b/static/js/08-settings.js @@ -0,0 +1,451 @@ +// static/js/08-settings.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.state = Glow.state || {}; +Glow.state.settings = Glow.state.settings || null; + +Glow.applyThemeMode = mode => { + const normalized = mode === "light" ? "light" : "dark"; + document.body.classList.toggle("theme-light", normalized === "light"); + localStorage.setItem("glow_theme_mode", normalized); +}; + +Glow.applyCustomCss = cssText => { + let style = document.getElementById("glowCustomCss"); + + if (!style) { + style = document.createElement("style"); + style.id = "glowCustomCss"; + document.head.append(style); + } + + style.textContent = typeof cssText === "string" ? cssText : ""; +}; + +Glow.loadMySettings = async () => { + try { + const data = await Glow.apiGetMySettings(); + Glow.state.settings = data.settings || {}; + + const accent = Glow.state.settings.accent_color || Glow.state.accentColor; + 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 (_) { + const mode = localStorage.getItem("glow_theme_mode") || "dark"; + Glow.applyThemeMode(mode); + } +}; + +Glow.renderHeaderMenu = () => { + const list = Glow.els.headerMenuList; + if (!list) return; + + list.replaceChildren(); + + const addItem = ({ label, hint = "", onClick, hidden = false }) => { + if (hidden) return; + + const button = document.createElement("button"); + button.className = "header-menu-item"; + button.type = "button"; + + const left = document.createElement("span"); + left.textContent = label; + + const right = document.createElement("small"); + right.textContent = hint; + + button.append(left, right); + button.addEventListener("click", () => { + Glow.closeHeaderMenu?.(); + onClick?.(); + }); + + list.append(button); + }; + + addItem({ + label: "My profile", + hint: Glow.state.currentUser ? `@${Glow.state.currentUser.username}` : "", + onClick: () => Glow.openMyProfile?.(), + hidden: !Glow.state.currentUser + }); + + addItem({ + label: "Settings", + hint: "Theme, CSS, profile", + onClick: () => Glow.openSettings?.() + }); + + addItem({ + label: "Direct messages", + hint: Glow.state.currentUser ? "Private chat" : "", + onClick: async () => { + Glow.state.activeView = "dms"; + Glow.updateViewUI(); + await Glow.loadDms?.(); + Glow.schedulePoll?.(); + }, + hidden: !Glow.state.currentUser + }); + + addItem({ + label: "Create team", + hint: "Up to 3 max", + onClick: () => Glow.openCreateTeamDialog?.(), + hidden: !Glow.state.currentUser + }); + + addItem({ + label: "Edit current team", + hint: Glow.state.activeTeam?.can_manage ? "Manage" : "", + onClick: () => Glow.openEditTeamDialog?.(Glow.state.activeTeam), + hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage + }); + + addItem({ + label: "Add channel", + hint: Glow.state.activeTeam?.name || "", + onClick: () => Glow.openCreateChannelDialog?.(Glow.state.activeTeam), + hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage + }); + + addItem({ + label: "Edit current channel", + hint: Glow.state.activeChannel?.name || Glow.state.activeRoom?.name || "", + onClick: () => Glow.openEditChannelDialog?.( + Glow.state.activeTeam, + Glow.state.activeChannel || Glow.state.activeRoom + ), + hidden: + !Glow.state.activeTeam || + !Glow.state.activeTeam.can_manage || + !(Glow.state.activeChannel || Glow.state.activeRoom) || + Glow.state.activeView === "dm" + }); + + addItem({ + label: "Manage team members", + hint: Glow.state.activeTeam?.can_manage ? "Members" : "", + onClick: () => Glow.manageTeamMembers?.(Glow.state.activeTeam), + hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage + }); + + addItem({ + label: "Create team invite", + hint: Glow.state.activeTeam?.can_manage ? "Share" : "", + onClick: () => Glow.createTeamInviteLink?.(Glow.state.activeTeam), + hidden: !Glow.state.activeTeam || !Glow.state.activeTeam.can_manage + }); + + addItem({ + label: "Delete current channel", + hint: Glow.state.activeChannel?.name || "", + onClick: () => Glow.deleteCurrentChannel?.(Glow.state.activeTeam), + hidden: + !Glow.state.activeTeam || + !Glow.state.activeTeam.can_manage || + !Glow.state.activeChannel || + Boolean(Glow.state.activeChannel?.legacy_room_id) + }); +}; + +Glow.openHeaderMenu = () => { + Glow.renderHeaderMenu(); + Glow.els.headerMenuOverlay?.classList.remove("hidden"); +}; + +Glow.closeHeaderMenu = () => { + Glow.els.headerMenuOverlay?.classList.add("hidden"); +}; + +Glow.toggleHeaderMenu = () => { + if (Glow.els.headerMenuOverlay?.classList.contains("hidden")) { + Glow.openHeaderMenu(); + } else { + Glow.closeHeaderMenu(); + } +}; + +Glow.openSettings = async () => { + Glow.state.activeView = "settings"; + Glow.updateViewUI(); + + if (!Glow.state.settings && Glow.state.currentUser) { + await Glow.loadMySettings(); + } + + Glow.renderSettingsView(); +}; + +Glow.renderNameColorPreview = value => { + const wrap = document.createElement("div"); + wrap.className = "settings-row"; + + const label = document.createElement("div"); + label.className = "header-subtitle"; + label.textContent = "Preview"; + + const preview = document.createElement("div"); + preview.className = "name-color-preview"; + preview.textContent = + Glow.state.currentUser?.username || "username"; + preview.style.color = + Glow.normalizeNameColor(value) || + Glow.roleDefaultNameColor(Glow.state.currentUser?.role); + + wrap.append(label, preview); + return wrap; +}; + +Glow.renderSettingsView = () => { + const box = Glow.els.profileBox; + box.replaceChildren(); + + const card = document.createElement("div"); + card.className = "settings-card"; + + const appearance = document.createElement("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, and custom CSS."; + + const themeRow = document.createElement("div"); + themeRow.className = "settings-theme-row"; + + const currentTheme = + Glow.state.settings?.theme_mode || + localStorage.getItem("glow_theme_mode") || + "dark"; + + const darkButton = document.createElement("button"); + 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 nameColorWrap = document.createElement("div"); + nameColorWrap.className = "settings-row"; + + const nameColorLabel = document.createElement("label"); + nameColorLabel.className = "dialog-label"; + nameColorLabel.textContent = "Username color"; + + const nameColorInput = document.createElement("input"); + nameColorInput.type = "color"; + nameColorInput.className = "accent-color-input"; + nameColorInput.value = + Glow.normalizeNameColor(Glow.state.settings?.name_color) || + Glow.roleDefaultNameColor(Glow.state.currentUser?.role); + + const nameColorText = document.createElement("input"); + nameColorText.className = "field accent-hex-input"; + nameColorText.type = "text"; + nameColorText.maxLength = 7; + nameColorText.value = + Glow.normalizeNameColor(Glow.state.settings?.name_color) || + ""; + nameColorText.placeholder = "#7dd3fc"; + + const nameRow = document.createElement("div"); + nameRow.className = "accent-picker-row"; + + const previewSwatch = document.createElement("span"); + previewSwatch.className = "accent-preview"; + previewSwatch.style.background = + Glow.normalizeNameColor(Glow.state.settings?.name_color) || + 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", () => { + nameColorText.value = nameColorInput.value; + Glow.state.settings ||= {}; + Glow.state.settings.name_color = nameColorInput.value; + updatePreview(nameColorInput.value); + Glow.renderSettingsView(); + }); + + nameColorText.addEventListener("input", () => { + const normalized = Glow.normalizeNameColor(nameColorText.value.trim()); + Glow.state.settings ||= {}; + Glow.state.settings.name_color = normalized || null; + updatePreview(nameColorText.value.trim()); + }); + + const resetNameColor = document.createElement("button"); + resetNameColor.className = "button secondary"; + resetNameColor.type = "button"; + resetNameColor.textContent = "Use role default"; + resetNameColor.addEventListener("click", () => { + Glow.state.settings ||= {}; + Glow.state.settings.name_color = null; + Glow.renderSettingsView(); + }); + + nameRow.append(nameColorInput, nameColorText, previewSwatch); + nameColorLabel.append(nameRow); + nameColorWrap.append(nameColorLabel, help, Glow.renderNameColorPreview( + Glow.state.settings?.name_color + ), resetNameColor); + + appearance.append(aTitle, aText, themeRow, accentWrap, nameColorWrap); + + const cssSection = document.createElement("section"); + cssSection.className = "settings-section"; + + const cTitle = document.createElement("h2"); + cTitle.textContent = "Custom CSS"; + + const cText = document.createElement("p"); + cText.textContent = + "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"); + cssInput.className = "field settings-custom-css"; + cssInput.placeholder = "/* Custom CSS */"; + cssInput.value = Glow.state.settings?.custom_css || ""; + + cssInput.addEventListener("input", () => { + Glow.applyCustomCss(cssInput.value); + Glow.state.settings ||= {}; + Glow.state.settings.custom_css = cssInput.value; + }); + + cssSection.append(cTitle, cText, cssInput); + + const accountSection = document.createElement("section"); + accountSection.className = "settings-section"; + + const accountTitle = document.createElement("h2"); + accountTitle.textContent = "Account"; + + const accountText = document.createElement("p"); + accountText.textContent = "Update your public profile and application preferences."; + + const actions = document.createElement("div"); + actions.className = "settings-actions"; + + const editProfile = document.createElement("button"); + editProfile.className = "button"; + editProfile.type = "button"; + editProfile.textContent = "Edit profile"; + editProfile.addEventListener("click", () => { + 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" + }; + + const data = await Glow.apiUpdateMySettings(payload); + Glow.state.settings = data.settings || payload; + Glow.showToast("Settings saved."); + Glow.renderSettingsView(); + } catch (error) { + Glow.showToast(error.message || "Could not save settings.", "error"); + } + }); + + const resetAccent = document.createElement("button"); + resetAccent.className = "button secondary"; + resetAccent.type = "button"; + resetAccent.textContent = "Reset accent"; + resetAccent.addEventListener("click", () => { + Glow.resetAccentColor(); + Glow.state.settings ||= {}; + Glow.state.settings.accent_color = Glow.DEFAULT_ACCENT_COLOR; + Glow.renderSettingsView(); + }); + + actions.append(editProfile, saveSettings, resetAccent); + accountSection.append(accountTitle, accountText, actions); + + card.append(appearance, cssSection, accountSection); + box.append(card); +}; diff --git a/static/js/09-profile.js b/static/js/09-profile.js new file mode 100644 index 0000000..dcdef31 --- /dev/null +++ b/static/js/09-profile.js @@ -0,0 +1,349 @@ +// static/js/09-profile.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.escapeHtml = value => + String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); + +Glow.renderProfileBody = profile => { + const wrap = document.createElement("div"); + wrap.className = "profile-rendered"; + + const content = String(profile?.bio_html || "").trim(); + + if (!content) { + const empty = document.createElement("div"); + empty.className = "profile-empty"; + empty.textContent = "No profile bio yet."; + return empty; + } + + wrap.innerHTML = content; + return wrap; +}; + +Glow.openProfile = async username => { + Glow.state.activeView = "profile"; + Glow.state.activeProfile = null; + Glow.updateViewUI(); + + const box = Glow.els.profileBox; + box.setAttribute("aria-busy", "true"); + box.replaceChildren(); + + try { + const data = await Glow.apiGetPublicProfile(username); + Glow.state.activeProfile = data.profile; + Glow.renderProfileView(false); + } catch (error) { + box.replaceChildren(); + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = error.message || "Could not load profile."; + + state.append(card); + box.append(state); + + Glow.showToast(error.message || "Could not load profile.", "error"); + } finally { + box.setAttribute("aria-busy", "false"); + } +}; + +Glow.openMyProfile = async (edit = false) => { + if (!Glow.state.currentUser) { + Glow.showToast("Sign in to view your profile.", "error"); + return; + } + + Glow.state.activeView = "profile"; + Glow.state.activeProfile = null; + Glow.updateViewUI(); + + const box = Glow.els.profileBox; + box.setAttribute("aria-busy", "true"); + box.replaceChildren(); + + try { + const data = await Glow.apiGetMyProfile(); + Glow.state.activeProfile = data.profile; + Glow.renderProfileView(edit); + } catch (error) { + Glow.showToast(error.message || "Could not load your profile.", "error"); + } finally { + box.setAttribute("aria-busy", "false"); + } +}; + +Glow.renderProfileView = (editing = false) => { + const box = Glow.els.profileBox; + const profile = Glow.state.activeProfile; + + box.replaceChildren(); + + if (!profile) { + const state = document.createElement("div"); + state.className = "empty-state"; + + const card = document.createElement("div"); + card.className = "empty-card"; + card.textContent = "Profile not found."; + + state.append(card); + box.append(state); + return; + } + + const card = document.createElement("div"); + card.className = "profile-card"; + + const banner = document.createElement("div"); + banner.className = "profile-banner"; + + const content = document.createElement("div"); + content.className = "profile-content"; + + const header = document.createElement("div"); + header.className = "profile-header"; + + const avatar = document.createElement("div"); + avatar.className = "profile-avatar"; + avatar.textContent = (profile.display_name || profile.username || "?") + .slice(0, 1) + .toUpperCase(); + + const actions = document.createElement("div"); + actions.className = "profile-actions"; + + if (profile.is_self) { + const editButton = document.createElement("button"); + editButton.className = "button"; + editButton.type = "button"; + editButton.textContent = editing ? "View profile" : "Edit profile"; + editButton.addEventListener("click", () => { + Glow.renderProfileView(!editing); + }); + + const settingsButton = document.createElement("button"); + settingsButton.className = "button secondary"; + settingsButton.type = "button"; + settingsButton.textContent = "Settings"; + settingsButton.addEventListener("click", () => { + Glow.openSettings?.(); + }); + + actions.append(editButton, settingsButton); + } + + header.append(avatar, actions); + + const name = document.createElement("div"); + name.className = "profile-name"; + name.textContent = profile.display_name || profile.username; + name.style.color = Glow.resolveUserNameColor({ + role: profile.role, + name_color: profile.name_color + }); + + const handle = document.createElement("div"); + handle.className = "profile-handle"; + handle.textContent = `@${profile.username}`; + handle.style.color = Glow.resolveUserNameColor({ + role: profile.role, + name_color: profile.name_color + }); + + const badges = document.createElement("div"); + badges.className = "profile-badges"; + + const roleBadge = document.createElement("span"); + roleBadge.className = "profile-badge"; + roleBadge.textContent = profile.role; + + const statusBadge = document.createElement("span"); + statusBadge.className = "profile-badge"; + statusBadge.textContent = profile.status; + + badges.append(roleBadge, statusBadge); + + const grid = document.createElement("div"); + grid.className = "profile-grid"; + + if (editing && profile.is_self) { + grid.append(Glow.buildProfileEditor(profile)); + } else { + const bioSection = document.createElement("section"); + bioSection.className = "profile-section"; + + const bioTitle = document.createElement("h3"); + bioTitle.textContent = "Bio"; + + bioSection.append(bioTitle, Glow.renderProfileBody(profile)); + + const metaSection = document.createElement("section"); + metaSection.className = "profile-section"; + + const metaTitle = document.createElement("h3"); + metaTitle.textContent = "Profile"; + + const metaList = document.createElement("dl"); + metaList.className = "profile-meta-list"; + + const addMeta = (label, value) => { + const dt = document.createElement("dt"); + dt.textContent = label; + + const dd = document.createElement("dd"); + dd.textContent = value || "—"; + + metaList.append(dt, dd); + }; + + addMeta("Display name", profile.display_name || profile.username); + addMeta("Website", profile.website || ""); + addMeta("Location", profile.location || ""); + addMeta( + "Joined", + profile.created_at ? new Date(profile.created_at).toLocaleDateString() : "" + ); + addMeta("Bio mode", profile.bio_mode || "txt"); + addMeta("Name color", profile.name_color || "role default"); + + metaSection.append(metaTitle, metaList); + grid.append(bioSection, metaSection); + } + + content.append(header, name, handle, badges, grid); + card.append(banner, content); + box.append(card); +}; + +Glow.buildProfileEditor = profile => { + const section = document.createElement("section"); + section.className = "profile-section"; + + const title = document.createElement("h3"); + title.textContent = "Edit profile"; + + const editor = document.createElement("div"); + editor.className = "profile-editor"; + + const displayName = document.createElement("input"); + displayName.className = "field"; + displayName.type = "text"; + displayName.maxLength = 80; + displayName.placeholder = "Display name"; + displayName.value = profile.display_name || ""; + + const website = document.createElement("input"); + website.className = "field"; + website.type = "url"; + website.maxLength = 300; + website.placeholder = "Website URL"; + website.value = profile.website || ""; + + const location = document.createElement("input"); + location.className = "field"; + location.type = "text"; + location.maxLength = 120; + location.placeholder = "Location"; + location.value = profile.location || ""; + + const modeRow = document.createElement("div"); + modeRow.className = "profile-mode-row"; + + let selectedMode = profile.bio_mode || "txt"; + + const body = document.createElement("textarea"); + body.className = "field"; + body.maxLength = 5000; + body.placeholder = "Tell people about yourself"; + body.value = profile.bio || ""; + + for (const mode of ["txt", "md", "html"]) { + const button = document.createElement("button"); + button.className = `mode-pill${selectedMode === mode ? " active" : ""}`; + button.type = "button"; + button.textContent = mode.toUpperCase(); + + button.addEventListener("click", () => { + selectedMode = mode; + for (const el of modeRow.querySelectorAll(".mode-pill")) { + el.classList.toggle("active", el.textContent.toLowerCase() === mode); + } + }); + + modeRow.append(button); + } + + const help = document.createElement("p"); + help.className = "header-subtitle"; + help.textContent = + "HTML is sanitized on save/display. Scripts, event handlers, and unsafe URLs are removed."; + + const actions = document.createElement("div"); + actions.className = "settings-actions"; + + const save = document.createElement("button"); + save.className = "button"; + save.type = "button"; + save.textContent = "Save profile"; + save.addEventListener("click", async () => { + try { + const data = await Glow.apiUpdateMyProfile({ + display_name: displayName.value, + website: website.value, + location: location.value, + bio: body.value, + bio_mode: selectedMode + }); + + Glow.state.activeProfile = { + ...data.profile, + name_color: Glow.state.settings?.name_color || profile.name_color || null + }; + Glow.showToast("Profile updated."); + Glow.renderProfileView(false); + } catch (error) { + Glow.showToast(error.message || "Could not update profile.", "error"); + } + }); + + const preview = document.createElement("button"); + preview.className = "button secondary"; + preview.type = "button"; + preview.textContent = "Preview"; + preview.addEventListener("click", async () => { + try { + const data = await Glow.apiUpdateMyProfile({ + display_name: displayName.value, + website: website.value, + location: location.value, + bio: body.value, + bio_mode: selectedMode + }); + + Glow.state.activeProfile = { + ...data.profile, + name_color: Glow.state.settings?.name_color || profile.name_color || null + }; + Glow.renderProfileView(false); + } catch (error) { + Glow.showToast(error.message || "Could not preview profile.", "error"); + } + }); + + actions.append(save, preview); + editor.append(displayName, website, location, modeRow, help, body, actions); + section.append(title, editor); + + return section; +}; diff --git a/static/js/10-reactions.js b/static/js/10-reactions.js new file mode 100644 index 0000000..b41cb59 --- /dev/null +++ b/static/js/10-reactions.js @@ -0,0 +1,166 @@ +// static/js/10-reactions.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.reactions = Glow.reactions || { + enabled: true, + emojis: ["👍", "❤️", "🔥", "😂", "🎉", "👏", "😮", "😢", "👀", "✅", "🚀", "💡"], + activeMessage: null +}; + +Glow.submitCustomReaction = async () => { + const message = Glow.reactions.activeMessage; + const input = Glow.els.reactionCustomInput; + if (!message || !input) return; + + const emoji = input.value.trim(); + if (!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); + input.value = ""; + + if (Glow.state.activeView === "feed") { + Glow.loadFeed?.(); + } + } catch (error) { + Glow.showToast(error.message || "Could not update reaction.", "error"); + } +}; + +Glow.openReactionPopup = async message => { + Glow.reactions.activeMessage = message; + + const grid = Glow.els.reactionPopupGrid; + const list = Glow.els.reactionPopupList; + const customInput = Glow.els.reactionCustomInput; + + if (!grid || !list) return; + + grid.replaceChildren(); + list.replaceChildren(); + + for (const emoji of Glow.reactions.emojis) { + const button = document.createElement("button"); + button.className = "reaction-popup-button"; + button.type = "button"; + button.textContent = emoji; + + button.addEventListener("click", async () => { + 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); + + if (Glow.state.activeView === "feed") { + Glow.loadFeed?.(); + } + } catch (error) { + Glow.showToast(error.message || "Could not update reaction.", "error"); + } + }); + + grid.append(button); + } + + try { + const data = await Glow.apiGetMessageReactions(message.id); + message.reactions = data.reactions || []; + } catch (_) { + message.reactions = []; + } + + if (customInput) { + customInput.value = ""; + } + + Glow.renderReactionPopupUsers(message.reactions || []); + Glow.els.reactionPopupOverlay?.classList.remove("hidden"); + + setTimeout(() => { + Glow.els.reactionCustomInput?.focus(); + }, 0); +}; + +Glow.renderReactionPopupUsers = reactions => { + const list = Glow.els.reactionPopupList; + if (!list) return; + + list.replaceChildren(); + + if (!reactions.length) { + const empty = document.createElement("div"); + empty.className = "reaction-popup-user"; + empty.textContent = "No reactions yet."; + list.append(empty); + return; + } + + for (const reaction of reactions) { + const section = document.createElement("div"); + section.className = "reaction-popup-section"; + + const title = document.createElement("div"); + title.className = "reaction-popup-user"; + title.innerHTML = `${reaction.emoji} ${reaction.count}`; + section.append(title); + + for (const user of reaction.users || []) { + const row = document.createElement("div"); + row.className = "reaction-popup-user"; + + const left = document.createElement("span"); + 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); + } +}; + +Glow.closeReactionPopup = () => { + Glow.reactions.activeMessage = null; + if (Glow.els.reactionCustomInput) { + Glow.els.reactionCustomInput.value = ""; + } + Glow.els.reactionPopupOverlay?.classList.add("hidden"); +}; + +Glow.els.reactionCustomButton?.addEventListener("click", () => { + Glow.submitCustomReaction?.(); +}); + +Glow.els.reactionCustomInput?.addEventListener("keydown", event => { + if (event.key === "Enter") { + event.preventDefault(); + Glow.submitCustomReaction?.(); + } +}); diff --git a/static/js/11-media.js b/static/js/11-media.js new file mode 100644 index 0000000..0bfc791 --- /dev/null +++ b/static/js/11-media.js @@ -0,0 +1,65 @@ +// static/js/11-media.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.resetMediaModal = () => { + if (!Glow.els.mediaOverlay) return; + + Glow.els.mediaImage.classList.add("hidden"); + Glow.els.mediaAudio.classList.add("hidden"); + Glow.els.mediaVideo.classList.add("hidden"); + Glow.els.mediaFileFallback.classList.add("hidden"); + + Glow.els.mediaImage.removeAttribute("src"); + Glow.els.mediaImage.alt = ""; + + Glow.els.mediaAudio.pause(); + Glow.els.mediaAudio.removeAttribute("src"); + Glow.els.mediaAudio.load(); + + Glow.els.mediaVideo.pause(); + Glow.els.mediaVideo.removeAttribute("src"); + Glow.els.mediaVideo.load(); + + Glow.els.mediaTitle.textContent = "Media"; + Glow.els.mediaDownloadLink.href = "#"; + Glow.els.mediaDownloadLink.removeAttribute("download"); + Glow.els.mediaFallbackLink.href = "#"; +}; + +Glow.openMediaModal = ({ url, name = "Media", mimeType = "" }) => { + if (!url || !Glow.els.mediaOverlay) return; + + Glow.resetMediaModal(); + + const mime = String(mimeType || "").toLowerCase(); + + Glow.els.mediaTitle.textContent = name; + Glow.els.mediaDownloadLink.href = url; + Glow.els.mediaDownloadLink.download = name; + Glow.els.mediaFallbackLink.href = url; + + if (mime.startsWith("image/")) { + Glow.els.mediaImage.src = url; + Glow.els.mediaImage.alt = name; + Glow.els.mediaImage.classList.remove("hidden"); + } else if (mime.startsWith("audio/")) { + Glow.els.mediaAudio.src = url; + Glow.els.mediaAudio.classList.remove("hidden"); + } else if (mime.startsWith("video/")) { + Glow.els.mediaVideo.src = url; + Glow.els.mediaVideo.classList.remove("hidden"); + } else { + Glow.els.mediaFileFallback.classList.remove("hidden"); + } + + Glow.els.mediaOverlay.classList.remove("hidden"); +}; + +Glow.closeMediaModal = () => { + if (!Glow.els.mediaOverlay) return; + + Glow.els.mediaOverlay.classList.add("hidden"); + Glow.resetMediaModal(); +}; diff --git a/static/js/12-calls.js b/static/js/12-calls.js new file mode 100644 index 0000000..fe759fa --- /dev/null +++ b/static/js/12-calls.js @@ -0,0 +1,389 @@ +// static/js/12-calls.js +"use strict"; + +window.Glow = window.Glow || {}; + +Glow.calls = Glow.calls || {}; + +Glow.calls.iceServers = [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" } +]; + +Glow.getActiveDmPeer = () => { + if (!Glow.state.activeDm || !Glow.state.currentUser) return null; + + return (Glow.state.activeDm.members || []).find( + member => Number(member.id) !== Number(Glow.state.currentUser.id) + ) || null; +}; + +Glow.renderCallUi = () => { + const call = Glow.state.calls.activeCall; + const hasCall = Boolean(call); + const incoming = Glow.state.calls.incomingCall; + const peer = Glow.getActiveDmPeer(); + + if (!Glow.els.callStatus) return; + + Glow.els.callPeerLabel.textContent = + peer?.display_name || peer?.username || "No active call"; + + Glow.els.callStatus.textContent = incoming + ? "Incoming call" + : hasCall + ? call.active + ? "In call" + : "Ended" + : "Idle"; + + Glow.els.startCallButton?.classList.toggle( + "hidden", + !Glow.state.activeDm || hasCall || Boolean(incoming) + ); + + Glow.els.answerCallButton?.classList.toggle("hidden", !incoming); + + Glow.els.muteCallButton?.classList.toggle("hidden", !hasCall); + Glow.els.hangupCallButton?.classList.toggle("hidden", !hasCall && !incoming); + + Glow.els.muteCallButton.textContent = + Glow.state.calls.muted ? "Unmute" : "Mute"; +}; + +Glow.openCallPanel = () => { + Glow.els.callOverlay?.classList.remove("hidden"); + Glow.renderCallUi(); +}; + +Glow.endLocalCallUi = async () => { + try { + if (Glow.state.calls.peerConnection) { + Glow.state.calls.peerConnection.onicecandidate = null; + Glow.state.calls.peerConnection.ontrack = null; + Glow.state.calls.peerConnection.close(); + } + } catch (_) {} + + try { + if (Glow.state.calls.localStream) { + for (const track of Glow.state.calls.localStream.getTracks()) { + track.stop(); + } + } + } catch (_) {} + + Glow.state.calls.peerConnection = null; + Glow.state.calls.localStream = null; + Glow.state.calls.activeCall = null; + Glow.state.calls.incomingCall = null; + Glow.state.calls.pendingOffer = null; + Glow.state.calls.signalLastSeenId = 0; + Glow.state.calls.muted = false; + + clearTimeout(Glow.state.calls.pollTimer); + + if (Glow.els.remoteAudio) { + Glow.els.remoteAudio.srcObject = null; + } + + Glow.renderCallUi(); +}; + +Glow.ensurePeerConnection = async () => { + if (Glow.state.calls.peerConnection) { + return Glow.state.calls.peerConnection; + } + + const pc = new RTCPeerConnection({ + iceServers: Glow.calls.iceServers + }); + + pc.onicecandidate = async event => { + if (!event.candidate || !Glow.state.calls.activeCall) return; + + try { + await Glow.apiPostCallSignal(Glow.state.calls.activeCall.id, { + signal_type: "ice", + payload: { candidate: event.candidate } + }); + } catch (_) {} + }; + + pc.ontrack = event => { + const stream = event.streams?.[0]; + if (stream && Glow.els.remoteAudio) { + Glow.els.remoteAudio.srcObject = stream; + } + }; + + Glow.state.calls.peerConnection = pc; + return pc; +}; + +Glow.ensureLocalAudio = async () => { + if (Glow.state.calls.localStream) { + return Glow.state.calls.localStream; + } + + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: false + }); + + Glow.state.calls.localStream = stream; + return stream; +}; + +Glow.attachLocalTracks = async () => { + const pc = await Glow.ensurePeerConnection(); + const stream = await Glow.ensureLocalAudio(); + + const existingSenders = pc.getSenders().map(sender => sender.track).filter(Boolean); + + for (const track of stream.getTracks()) { + if (!existingSenders.includes(track)) { + pc.addTrack(track, stream); + } + } +}; + +Glow.startCall = async () => { + if (!Glow.state.activeDm) { + Glow.showToast("Open a DM to start a call.", "error"); + return; + } + + if (!navigator.mediaDevices?.getUserMedia || !window.RTCPeerConnection) { + Glow.showToast("Voice calls are not supported in this browser.", "error"); + return; + } + + try { + const data = await Glow.apiCreateCall(Glow.state.activeDm.id); + Glow.state.calls.activeCall = data.call; + Glow.state.calls.incomingCall = null; + Glow.state.calls.pendingOffer = null; + Glow.state.calls.signalLastSeenId = 0; + + Glow.openCallPanel(); + await Glow.attachLocalTracks(); + + const pc = await Glow.ensurePeerConnection(); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + + await Glow.apiPostCallSignal(data.call.id, { + signal_type: "offer", + payload: { sdp: offer } + }); + + await Glow.apiPostCallSignal(data.call.id, { + signal_type: "ring", + payload: {} + }); + + Glow.renderCallUi(); + Glow.scheduleCallPolling(); + } catch (error) { + Glow.showToast(error.message || "Could not start call.", "error"); + await Glow.endLocalCallUi(); + } +}; + +Glow.answerIncomingCall = async () => { + const incoming = Glow.state.calls.incomingCall; + const pendingOffer = Glow.state.calls.pendingOffer; + + if (!incoming || !pendingOffer) return; + + try { + Glow.state.calls.activeCall = incoming; + Glow.state.calls.incomingCall = null; + Glow.openCallPanel(); + + await Glow.attachLocalTracks(); + + const pc = await Glow.ensurePeerConnection(); + await pc.setRemoteDescription(new RTCSessionDescription(pendingOffer)); + + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + + await Glow.apiPostCallSignal(incoming.id, { + signal_type: "answer", + payload: { sdp: answer } + }); + + Glow.state.calls.pendingOffer = null; + Glow.renderCallUi(); + Glow.scheduleCallPolling(); + } catch (error) { + Glow.showToast(error.message || "Could not answer call.", "error"); + await Glow.endLocalCallUi(); + } +}; + +Glow.handleCallSignal = async signal => { + const activeCall = Glow.state.calls.activeCall; + const incomingCall = Glow.state.calls.incomingCall; + + if (signal.signal_type === "ring") { + if (!activeCall && !incomingCall) { + try { + const data = await Glow.apiGetCall(signal.call_session_id); + Glow.state.calls.incomingCall = data.call; + Glow.openCallPanel(); + Glow.renderCallUi(); + } catch (_) {} + } + return; + } + + if (signal.signal_type === "offer") { + try { + const data = await Glow.apiGetCall(signal.call_session_id); + Glow.state.calls.incomingCall = data.call; + Glow.state.calls.pendingOffer = signal.payload?.sdp || signal.payload; + Glow.openCallPanel(); + Glow.renderCallUi(); + } catch (_) {} + return; + } + + if (!Glow.state.calls.activeCall || Number(Glow.state.calls.activeCall.id) !== Number(signal.call_session_id)) { + return; + } + + const pc = await Glow.ensurePeerConnection(); + + if (signal.signal_type === "answer") { + const sdp = signal.payload?.sdp || signal.payload; + if (sdp) { + await pc.setRemoteDescription(new RTCSessionDescription(sdp)); + } + return; + } + + if (signal.signal_type === "ice") { + const candidate = signal.payload?.candidate || signal.payload; + if (candidate) { + try { + await pc.addIceCandidate(new RTCIceCandidate(candidate)); + } catch (_) {} + } + return; + } + + if (signal.signal_type === "hangup") { + Glow.showToast("Call ended."); + await Glow.endLocalCallUi(); + return; + } +}; + +Glow.pollCallSignals = async () => { + const activeCall = Glow.state.calls.activeCall; + const incomingCall = Glow.state.calls.incomingCall; + const call = activeCall || incomingCall; + + if (!call || document.hidden) return; + + try { + const data = await Glow.apiGetCallSignals( + call.id, + Glow.state.calls.signalLastSeenId || 0 + ); + + const signals = data.signals || []; + + for (const signal of signals) { + Glow.state.calls.signalLastSeenId = Math.max( + Glow.state.calls.signalLastSeenId || 0, + Number(signal.id) || 0 + ); + + if ( + Glow.state.currentUser && + Number(signal.user_id) === Number(Glow.state.currentUser.id) + ) { + continue; + } + + await Glow.handleCallSignal(signal); + } + } catch (_) {} +}; + +Glow.scheduleCallPolling = () => { + clearTimeout(Glow.state.calls.pollTimer); + + if (!Glow.state.calls.activeCall && !Glow.state.calls.incomingCall) { + return; + } + + Glow.state.calls.pollTimer = setTimeout(async () => { + await Glow.pollCallSignals(); + Glow.scheduleCallPolling(); + }, document.hidden ? 5000 : 2000); +}; + +Glow.toggleMuteCall = () => { + const stream = Glow.state.calls.localStream; + if (!stream) return; + + Glow.state.calls.muted = !Glow.state.calls.muted; + + for (const track of stream.getAudioTracks()) { + track.enabled = !Glow.state.calls.muted; + } + + Glow.renderCallUi(); +}; + +Glow.hangupCall = async () => { + const call = Glow.state.calls.activeCall || Glow.state.calls.incomingCall; + if (!call) return; + + try { + await Glow.apiEndCall(call.id); + } catch (_) { + // ignore + } + + Glow.showToast("Call ended."); + await Glow.endLocalCallUi(); +}; + +Glow.syncActiveCallForDm = async () => { + if (!Glow.state.activeDm) return; + + try { + const createResult = await Glow.apiCreateCall(Glow.state.activeDm.id); + const call = createResult.call; + + if (call?.active) { + Glow.state.calls.activeCall = call; + Glow.renderCallUi(); + Glow.scheduleCallPolling(); + } + } catch (_) { + // ignore if no active call semantics vary + } +}; + +Glow.els.startCallButton?.addEventListener("click", () => { + Glow.startCall?.(); +}); + +Glow.els.answerCallButton?.addEventListener("click", () => { + Glow.answerIncomingCall?.(); +}); + +Glow.els.muteCallButton?.addEventListener("click", () => { + Glow.toggleMuteCall?.(); +}); + +Glow.els.hangupCallButton?.addEventListener("click", () => { + Glow.hangupCall?.(); +}); diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..8fe20a0 --- /dev/null +++ b/static/style.css @@ -0,0 +1,2582 @@ +/* static/style.css */ +:root { + color-scheme: dark; + + --bg: #000; + --surface: #050506; + --surface-raised: #121214; + --surface-hover: #1b1b1e; + --surface-active: #26262a; + --surface-overlay: rgba(0, 0, 0, 0.86); + + --text: #f5f5f7; + --text-soft: #d1d1d6; + --muted: #8e8e93; + --muted-dark: #5a5a60; + + --line: rgba(255, 255, 255, 0.10); + --line-strong: rgba(255, 255, 255, 0.18); + + --accent: #f7dce8; + --accent-soft: rgba(247, 220, 232, 0.16); + --accent-hover: rgba(247, 220, 232, 0.24); + --accent-active: #fff3f8; + + --success: #30d158; + --danger: #ff453a; + --danger-bg: rgba(92, 15, 22, 0.92); + --danger-soft: rgba(255, 69, 58, 0.18); + --danger-text: #ffd7d4; + + --shadow: 0 16px 44px rgba(0, 0, 0, 0.68); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.42); + + --radius-xs: 3px; + --radius-sm: 5px; + --radius: 7px; + --radius-lg: 12px; + + --safe-top: env(safe-area-inset-top, 0px); + --safe-right: env(safe-area-inset-right, 0px); + --safe-bottom: env(safe-area-inset-bottom, 0px); + --safe-left: env(safe-area-inset-left, 0px); +} + +body.theme-light { + color-scheme: light; + --bg: #f5f5f7; + --surface: #ffffff; + --surface-raised: #f3f3f5; + --surface-hover: #ebebef; + --surface-active: #dfdfe4; + --surface-overlay: rgba(255, 255, 255, 0.82); + + --text: #111214; + --text-soft: #22252a; + --muted: #60646b; + --muted-dark: #81848c; + + --line: rgba(0, 0, 0, 0.10); + --line-strong: rgba(0, 0, 0, 0.18); + + --accent-active: #151515; + --shadow: 0 16px 44px rgba(0, 0, 0, 0.12); + --shadow-soft: 0 8px 24px rgba(0, 0, 0, 0.10); +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: var(--bg); +} + +body { + background: var(--bg); + color: var(--text); + font-family: + -apple-system, + BlinkMacSystemFont, + "SF Pro Display", + "SF Pro Text", + "Helvetica Neue", + Helvetica, + Arial, + sans-serif; + font-size: 15px; + font-weight: 400; + letter-spacing: -0.01em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +button, +input, +textarea, +select { + font: inherit; +} + +button, +label { + -webkit-tap-highlight-color: transparent; +} + +button { + cursor: pointer; +} + +button:disabled, +input:disabled, +textarea:disabled, +select:disabled, +.disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; + margin: -1px; + white-space: nowrap; +} + +/* App layout */ + +.app-shell { + display: flex; + flex-direction: column; + width: 100%; + height: 100dvh; + overflow: hidden; + background: var(--bg); +} + +.chat-header { + z-index: 5; + flex: 0 0 auto; + min-height: calc(60px + var(--safe-top)); + padding: + var(--safe-top) + max(18px, var(--safe-right)) + 0 + max(18px, var(--safe-left)); + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(22px) saturate(160%); + -webkit-backdrop-filter: blur(22px) saturate(160%); +} + +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + width: min(100%, 980px); + height: 60px; + margin: 0 auto; + gap: 12px; +} + +.header-brand { + display: flex; + flex: 1 1 auto; + align-items: center; + min-width: 0; + gap: 9px; +} + +.header-room-copy { + min-width: 0; +} + +.header-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: var(--muted-dark); + box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.035); +} + +.header-dot.online { + background: var(--success); + box-shadow: 0 0 10px rgba(48, 209, 88, 0.30); +} + +.header-dot.new { + background: #ffd60a; + box-shadow: 0 0 10px rgba(255, 214, 10, 0.26); +} + +.header-title, +.header-subtitle, +.header-account { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.header-title { + color: var(--text); + font-size: 15px; + font-weight: 700; + letter-spacing: -0.03em; +} + +.header-subtitle { + margin-top: 1px; + color: var(--muted); + font-size: 11px; + font-weight: 500; + letter-spacing: -0.01em; +} + +.header-navigation { + display: flex; + align-items: center; + min-width: 0; + gap: 4px; + flex-wrap: nowrap; +} + +.header-account-wrap { + display: flex; + align-items: center; + min-width: 0; + gap: 7px; +} + +.header-account { + max-width: 180px; + color: var(--muted); + font-size: 11px; + font-weight: 500; + text-align: right; +} + +.presence-label { + min-width: 0; + max-width: 110px; + padding: 0 8px; + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-align: center; + white-space: nowrap; +} + +/* Header controls */ + +.header-action, +.feed-button, +.room-select { + min-height: 30px; + border: 0; + outline: none; + background-color: transparent; + color: var(--muted); + font-family: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: -0.01em; + text-decoration: none; + -webkit-font-smoothing: antialiased; +} + +.header-action, +.feed-button { + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: + background-color 0.16s ease, + color 0.16s ease, + opacity 0.16s ease; +} + +.header-action { + min-width: 38px; + min-height: 38px; + padding: 0.5rem; + border-radius: 0.625rem; +} + +.feed-button { + flex: 0 0 auto; + padding: 0 8px; + line-height: 30px; + border-radius: var(--radius-xs); +} + +.feed-button.active, +.pages-button.active, +.header-action[aria-pressed="true"], +.header-action.active { + color: var(--text-soft); +} + +.header-action:hover, +.feed-button:hover, +.feed-button.active:hover, +.header-action:focus-visible, +.feed-button:focus-visible { + color: var(--accent-active); + background: color-mix(in srgb, var(--text) 8%, transparent); + box-shadow: none; +} + +.header-action:focus-visible { + outline: 2px solid color-mix(in srgb, var(--text) 28%, transparent); + outline-offset: 2px; +} + +.feed-button:focus-visible, +.room-select:focus-visible { + outline: 1px solid var(--accent); + outline-offset: 2px; +} + +.header-action[aria-pressed="true"], +.header-action.active { + background: color-mix(in srgb, var(--text) 5%, transparent); +} + +.header-action:disabled { + opacity: 0.5; + cursor: default; +} + +.header-action svg { + display: block; + width: 22px; + height: 22px; + flex: 0 0 auto; + color: inherit; + overflow: visible; +} + +.header-navigation .header-action, +.header-account-wrap .header-action { + min-width: 38px; + min-height: 38px; +} + +.room-select { + min-width: 0; + max-width: 180px; + padding: 0 24px 0 8px; + border-radius: var(--radius-xs); + line-height: 30px; + appearance: none; + -webkit-appearance: none; + background-image: + linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: + calc(100% - 11px) 13px, + calc(100% - 7px) 13px; + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + cursor: pointer; + transition: color 120ms ease; +} + +.room-select option { + background: var(--surface-raised); + color: var(--text); +} + +.room-select.active { + color: var(--text-soft); +} + +.room-select:hover, +.room-select.active:hover, +.room-select:focus-visible { + background-color: transparent; + color: var(--accent-active); + box-shadow: none; +} + +.room-select:disabled, +.room-select:disabled:hover { + color: var(--muted-dark); + cursor: not-allowed; + opacity: 1; +} + +.team-select { + max-width: 148px; +} + +.channel-select { + max-width: 164px; +} + +.team-select, +.channel-select { + flex: 0 1 auto; +} + +/* Shared scrolling containers */ + +.chat-box, +.feed-box, +.thread-box, +.pages-box, +.page-detail-box, +.profile-box, +.dm-box { + width: min(100%, 980px); + flex: 1 1 auto; + min-height: 0; + margin: 0 auto; + padding: + 22px + max(18px, calc(var(--safe-left) + 18px)) + 18px + max(18px, calc(var(--safe-right) + 18px)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + scroll-behavior: smooth; +} + +.chat-box::-webkit-scrollbar, +.feed-box::-webkit-scrollbar, +.thread-box::-webkit-scrollbar, +.pages-box::-webkit-scrollbar, +.page-detail-box::-webkit-scrollbar, +.profile-box::-webkit-scrollbar, +.dm-box::-webkit-scrollbar, +.admin-users::-webkit-scrollbar, +.member-list::-webkit-scrollbar, +.reaction-popup-list::-webkit-scrollbar, +.settings-custom-css::-webkit-scrollbar { + width: 7px; +} + +.chat-box::-webkit-scrollbar-thumb, +.feed-box::-webkit-scrollbar-thumb, +.thread-box::-webkit-scrollbar-thumb, +.pages-box::-webkit-scrollbar-thumb, +.page-detail-box::-webkit-scrollbar-thumb, +.profile-box::-webkit-scrollbar-thumb, +.dm-box::-webkit-scrollbar-thumb, +.admin-users::-webkit-scrollbar-thumb, +.member-list::-webkit-scrollbar-thumb, +.reaction-popup-list::-webkit-scrollbar-thumb, +.settings-custom-css::-webkit-scrollbar-thumb { + border-radius: 99px; + background: #3a3a3e; +} + +.empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding: 28px; + color: var(--muted); + text-align: center; +} + +.empty-card { + width: min(100%, 360px); + padding: 20px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow-soft); + font-size: 13px; + line-height: 1.45; +} + +.date-separator { + display: flex; + align-items: center; + justify-content: center; + margin: 22px 0 14px; +} + +.date-separator span { + padding: 5px 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--bg); + color: var(--muted); + font-size: 11px; + font-weight: 600; +} + +/* Chat messages */ + +.message-row { + display: flex; + flex-direction: column; + width: 100%; + max-width: 100%; + margin: 8px 0; + animation: message-in 160ms ease-out both; +} + +.message-row.self { + align-items: flex-end; +} + +.message-row.grouped { + margin-top: 3px; +} + +.message-meta { + display: flex; + align-items: center; + max-width: 80%; + margin: 0 10px 4px; + gap: 6px; + overflow: hidden; + color: var(--muted); + font-size: 11px; +} + +.message-row.self .message-meta { + justify-content: flex-end; +} + +.message-author { + overflow: hidden; + color: var(--text-soft); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.message-author.profile-link, +.feed-entry-author.profile-link, +.profile-link { + border: 0; + padding: 0; + background: transparent; + font: inherit; + font-weight: 700; + text-decoration: none; +} + +.message-author.profile-link:hover, +.feed-entry-author.profile-link:hover, +.profile-link:hover, +.message-author.profile-link:focus-visible, +.feed-entry-author.profile-link:focus-visible, +.profile-link:focus-visible { + text-decoration: underline; + text-underline-offset: 2px; +} + +.message-time { + flex: 0 0 auto; + color: var(--muted); + white-space: nowrap; +} + +.message-content-row { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; +} + +.message-row.self .message-content-row { + justify-content: flex-end; +} + +.message-row:not(.self) .message-content-row { + justify-content: flex-start; +} + +.message-actions { + display: inline-flex; + align-items: center; + gap: 0.25rem; + flex: 0 0 auto; + opacity: 0; + pointer-events: none; + transform: translateY(2px); + transition: + opacity 0.16s ease, + transform 0.16s ease; + flex-wrap: wrap; +} + +.message-row:hover .message-actions, +.message-row:focus-within .message-actions { + opacity: 1; + pointer-events: auto; + transform: translateY(0); +} + +.message-row.self .message-actions { + justify-content: flex-end; +} + +.message-bubble { + display: block; + width: fit-content; + max-width: min(72ch, 78%); + padding: 0.75rem 0.95rem; + overflow-wrap: anywhere; + word-break: break-word; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 1rem; + background: rgba(37, 38, 39, 0.41); + color: #f3f5f7; + line-height: 1.42; + white-space: pre-wrap; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.16); +} + +body.theme-light .message-bubble { + background: rgba(0, 0, 0, 0.04); + color: var(--text); + border-color: rgba(0, 0, 0, 0.08); +} + +.message-row:not(.self) .message-bubble { + border-top-left-radius: 0.4rem; +} + +.message-row.self .message-bubble { + border-top-right-radius: 0.4rem; + border-top-left-radius: 1rem; + border-bottom-left-radius: 1rem; + border-bottom-right-radius: 0.4rem; + background: rgba(37, 38, 39, 0.61); + color: #fff; +} + +body.theme-light .message-row.self .message-bubble { + background: rgba(0, 0, 0, 0.10); + color: var(--text); +} + +.message-action, +.admin-delete, +.reaction-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + min-height: 28px; + padding: 0.35rem 0.6rem; + margin: 0; + border: 0; + border-radius: 999px; + outline: none; + background: rgba(255, 255, 255, 0.06); + color: #aeb6c2; + font: inherit; + font-size: 11px; + line-height: 1; + white-space: nowrap; + transition: + background-color 0.16s ease, + color 0.16s ease, + opacity 0.16s ease; +} + +body.theme-light .message-action, +body.theme-light .admin-delete, +body.theme-light .reaction-trigger { + background: rgba(0, 0, 0, 0.05); + color: var(--muted); +} + +.message-action:hover, +.message-action:focus-visible, +.admin-delete:hover, +.admin-delete:focus-visible, +.reaction-trigger:hover, +.reaction-trigger:focus-visible { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +body.theme-light .message-action:hover, +body.theme-light .message-action:focus-visible, +body.theme-light .admin-delete:hover, +body.theme-light .admin-delete:focus-visible, +body.theme-light .reaction-trigger:hover, +body.theme-light .reaction-trigger:focus-visible { + background: rgba(0, 0, 0, 0.08); + color: var(--text); +} + +.message-action:focus-visible, +.admin-delete:focus-visible, +.reaction-trigger:focus-visible { + outline: 2px solid rgba(255, 255, 255, 0.25); + outline-offset: 2px; +} + +.admin-delete { + color: #ff9b9b; + font-size: 18px; +} + +.admin-delete:hover, +.admin-delete:focus-visible { + background: rgba(255, 80, 80, 0.18); + color: #fff; +} + +.message-attachment { + display: inline-block; + margin-top: 8px; + color: inherit; + text-decoration: underline; +} + +.message-image-link { + display: block; + margin-top: 10px; + max-width: min(360px, 100%); +} + +.message-image-preview { + display: block; + width: 100%; + max-width: 100%; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); + object-fit: cover; + box-shadow: var(--shadow-soft); +} + +.message-video-preview { + display: block; + width: 100%; + max-width: min(460px, 100%); + margin-top: 10px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); + box-shadow: var(--shadow-soft); +} + +.message-audio-wrap, +.message-video-wrap { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.message-audio-wrap audio, +.message-video-wrap video, +.message-attachments-list audio { + width: 100%; + max-width: min(460px, 100%); +} + +.message-url-actions { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + gap: 6px; +} + +.message-row.grouped .message-meta { + display: none; +} + +.message-reactions { + display: flex; + flex-wrap: wrap; + margin-top: 8px; + gap: 6px; +} + +.reaction-chip { + display: inline-flex; + align-items: center; + gap: 5px; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-raised); + color: var(--text-soft); + font-size: 12px; + font-weight: 650; +} + +.reaction-chip.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-active); +} + +.reaction-chip:hover, +.reaction-chip:focus-visible { + border-color: var(--line-strong); +} + +/* Feed */ + +.feed-entry { + max-width: 100%; + margin: 10px 0; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.14); + animation: message-in 160ms ease-out both; +} + +.feed-entry.mention { + border-color: var(--accent); + background: var(--accent-soft); +} + +.feed-entry-heading { + display: flex; + align-items: center; + justify-content: space-between; + min-width: 0; + gap: 12px; +} + +.feed-room-link { + min-width: 0; + padding: 0; + overflow: hidden; + border: 0; + outline: none; + background: transparent; + color: var(--accent-active); + font-family: inherit; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.feed-room-link:hover, +.feed-room-link:focus-visible { + color: var(--text); + text-decoration: underline; + text-decoration-color: var(--accent); + text-underline-offset: 3px; +} + +.feed-entry-time { + flex: 0 0 auto; + color: var(--muted); + font-size: 11px; +} + +.feed-entry-author { + margin-top: 6px; + font-size: 12px; + font-weight: 700; +} + +.feed-entry-body { + margin-top: 7px; + overflow-wrap: break-word; + color: var(--text); + line-height: 1.45; + white-space: pre-wrap; +} + +.feed-open-room { + min-height: 30px; + margin-top: 12px; +} + +.feed-entry-reactions { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + gap: 6px; +} + +/* DM list */ + +.dm-list { + display: grid; + gap: 10px; +} + +.dm-card { + min-height: 0; +} + +/* Threads */ + +.thread-heading { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + padding-bottom: 12px; + border-bottom: 1px solid var(--line); +} + +.thread-label { + color: var(--text); + font-size: 15px; + font-weight: 750; +} + +.thread-count { + color: var(--muted); + font-size: 12px; +} + +.thread-root { + margin-bottom: 16px; +} + +.thread-replies-label { + margin: 16px 0 10px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.thread-message { + margin: 8px 0; +} + +.thread-empty { + padding: 22px 14px; + border: 1px dashed var(--line-strong); + border-radius: var(--radius); + color: var(--muted); + font-size: 13px; + text-align: center; +} + +/* Pages */ + +.pages-header, +.page-detail-header { + margin-bottom: 18px; +} + +.pages-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} + +.pages-header h2, +.page-detail-header h1 { + margin: 0; + color: var(--text); + font-weight: 750; + letter-spacing: -0.035em; +} + +.pages-header h2 { + font-size: 22px; +} + +.page-detail-header h1 { + margin-top: 10px; + font-size: clamp(26px, 5vw, 38px); + line-height: 1.08; +} + +.pages-header p, +.page-detail-context { + margin: 5px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.page-filters { + display: flex; + flex-wrap: wrap; + margin-bottom: 18px; + gap: 7px; +} + +.page-filter { + min-height: 31px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 999px; + outline: none; + background: transparent; + color: var(--muted); + font-family: inherit; + font-size: 12px; + font-weight: 650; +} + +.page-filter:hover, +.page-filter:focus-visible { + border-color: var(--line-strong); + color: var(--text-soft); +} + +.page-filter.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-active); +} + +.pages-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 10px; +} + +.page-card { + display: flex; + flex-direction: column; + min-height: 180px; + padding: 15px; + border: 1px solid var(--line); + border-radius: var(--radius); + outline: none; + background: var(--surface); + cursor: pointer; + transition: + border-color 120ms ease, + background-color 120ms ease, + transform 120ms ease; +} + +.page-card:hover, +.page-card:focus-visible { + border-color: var(--accent); + background: var(--surface-hover); + transform: translateY(-1px); +} + +.page-card-meta, +.page-detail-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; +} + +.page-badge { + display: inline-flex; + align-items: center; + min-height: 21px; + padding: 0 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 750; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.page-badge.type { + background: var(--surface-active); + color: var(--text-soft); +} + +.page-badge.visibility { + border: 1px solid var(--line); + color: var(--muted); +} + +.page-badge.visibility.public { + border-color: rgba(48, 209, 88, 0.36); + background: rgba(48, 209, 88, 0.10); + color: #85ef9e; +} + +.page-badge.visibility.team { + border-color: rgba(142, 142, 147, 0.32); + background: rgba(142, 142, 147, 0.10); + color: var(--text-soft); +} + +.page-card h3 { + margin: 12px 0 7px; + color: var(--text); + font-size: 16px; + font-weight: 750; + letter-spacing: -0.02em; +} + +.page-card-excerpt { + display: -webkit-box; + margin: 0; + overflow: hidden; + color: var(--muted); + font-size: 13px; + line-height: 1.45; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.page-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: auto; + padding-top: 14px; + gap: 8px; + color: var(--muted-dark); + font-size: 11px; +} + +.page-card-footer span, +.page-card-footer time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.page-updated { + color: var(--muted); + font-size: 12px; +} + +.page-body { + min-height: 180px; + padding: 20px; + overflow-wrap: break-word; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + color: var(--text); + font-size: 15px; + line-height: 1.65; + white-space: pre-wrap; +} + +.page-detail-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; + gap: 9px; +} + +.page-empty-state { + min-height: 260px; +} + +/* Profile view */ + +.profile-box { + display: block; +} + +.profile-card { + overflow: hidden; + border: 1px solid var(--line); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.profile-banner { + height: 140px; + background: + linear-gradient(135deg, var(--accent-soft), transparent 70%), + linear-gradient(45deg, color-mix(in srgb, var(--accent) 40%, transparent), transparent 60%), + var(--surface-active); +} + +.profile-content { + padding: 18px; +} + +.profile-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + margin-top: -48px; +} + +.profile-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 88px; + height: 88px; + border: 3px solid var(--surface); + border-radius: 50%; + background: var(--surface-raised); + color: var(--text); + font-size: 28px; + font-weight: 800; + box-shadow: var(--shadow-soft); +} + +.profile-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.profile-name { + margin: 14px 0 4px; + color: var(--text); + font-size: 28px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.profile-handle { + color: var(--muted); + font-size: 14px; +} + +.profile-badges { + display: flex; + flex-wrap: wrap; + margin-top: 10px; + gap: 8px; +} + +.profile-badge { + display: inline-flex; + align-items: center; + min-height: 26px; + padding: 0 9px; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--surface-raised); + color: var(--text-soft); + font-size: 11px; + font-weight: 700; +} + +.profile-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + margin-top: 20px; + gap: 14px; +} + +.profile-section { + padding: 15px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-raised); +} + +.profile-section h3 { + margin: 0 0 10px; + font-size: 14px; + font-weight: 800; + color: var(--text); +} + +.profile-bio, +.profile-rendered { + color: var(--text); + line-height: 1.55; + white-space: pre-wrap; +} + +.profile-empty { + color: var(--muted); + font-style: italic; +} + +.profile-meta-list { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 8px 10px; + color: var(--text-soft); + font-size: 13px; +} + +.profile-meta-list dt { + color: var(--muted); + font-weight: 700; +} + +.profile-meta-list dd { + margin: 0; +} + +.profile-editor { + display: grid; + gap: 10px; +} + +.profile-editor textarea.field { + min-height: 120px; +} + +.profile-mode-row, +.settings-theme-row { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mode-pill, +.theme-pill { + min-height: 32px; + padding: 0 11px; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.mode-pill.active, +.theme-pill.active { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--accent-active); +} + +/* Composer */ + +.composer-wrap { + z-index: 5; + flex: 0 0 auto; + padding: + 12px + max(18px, calc(var(--safe-right) + 18px)) + calc(10px + var(--safe-bottom)) + max(18px, calc(var(--safe-left) + 18px)); + background: color-mix(in srgb, var(--bg) 92%, transparent); + backdrop-filter: blur(22px) saturate(160%); + -webkit-backdrop-filter: blur(22px) saturate(160%); +} + +.composer { + display: flex; + align-items: flex-start; + width: min(100%, 980px); + gap: 9px; + margin: 0 auto; +} + +.composer-field-wrap { + flex: 1 1 auto; + min-width: 0; +} + +.composer-input, +.field { + width: 100%; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-raised); + color: var(--text); +} + +body.theme-light .composer-input, +body.theme-light .field { + border-color: rgba(0, 0, 0, 0.12); +} + +.composer-input { + display: block; + min-height: 44px; + max-height: 150px; + padding: 11px 13px; + resize: none; + overflow: hidden; + line-height: 1.35; +} + +.composer-input:hover, +.field:hover { + border-color: rgba(255, 255, 255, 0.28); +} + +body.theme-light .composer-input:hover, +body.theme-light .field:hover { + border-color: rgba(0, 0, 0, 0.20); +} + +.composer-input:focus, +.field:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.composer-input::placeholder, +.field::placeholder { + color: var(--muted); +} + +.composer-meta { + display: flex; + justify-content: space-between; + min-height: 18px; + padding: 3px 3px 0; + gap: 8px; + color: var(--muted); + font-size: 11px; +} + +.attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Shared controls */ + +.attachment-button, +.send-button, +.button, +.danger-button, +.small-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-raised); + color: var(--text-soft); + font-family: inherit; + font-weight: 650; + transition: + background-color 120ms ease, + border-color 120ms ease, + color 120ms ease; +} + +body.theme-light .attachment-button, +body.theme-light .send-button, +body.theme-light .button, +body.theme-light .danger-button, +body.theme-light .small-button { + border-color: rgba(0, 0, 0, 0.10); +} + +.attachment-button, +.send-button { + height: 44px; + min-height: 44px; + align-self: flex-start; +} + +.attachment-button { + width: 44px; + min-width: 44px; + flex: 0 0 44px; + padding: 0 0 1px; + font-size: 21px; + line-height: 1; +} + +.send-button { + min-width: 56px; + flex: 0 0 auto; + padding: 0 13px; + font-size: 14px; + white-space: nowrap; +} + +.button, +.danger-button { + min-height: 40px; + padding: 0 14px; +} + +.small-button { + min-height: 31px; + padding: 0 9px; + font-size: 11px; +} + +.attachment-button:hover, +.send-button:hover, +.button:hover, +.small-button:hover { + border-color: rgba(255, 255, 255, 0.26); + background: var(--surface-hover); + color: var(--text); +} + +.attachment-button:focus-visible, +.send-button:focus-visible, +.button:focus-visible, +.small-button:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.danger-button { + border-color: rgba(255, 69, 58, 0.34); + background: transparent; + color: #ff9d98; +} + +.danger-button:hover, +.small-button.danger:hover { + border-color: rgba(255, 69, 58, 0.48); + background: var(--danger-soft); + color: #ffc0bc; +} + +.small-button.danger { + color: #f1919b; +} + +/* Overlays and forms */ + +.auth-overlay, +.blocked-overlay, +.dialog-overlay, +.reaction-popup-overlay, +.media-overlay, +.header-menu-overlay, +.call-overlay { + position: fixed; + inset: 0; + z-index: 40; + display: flex; + align-items: center; + justify-content: center; + padding: + max(20px, var(--safe-top)) + max(20px, var(--safe-right)) + max(20px, var(--safe-bottom)) + max(20px, var(--safe-left)); + overflow: auto; + background: var(--surface-overlay); + backdrop-filter: blur(16px) saturate(150%); + -webkit-backdrop-filter: blur(16px) saturate(150%); +} + +.blocked-overlay { + z-index: 100; +} + +.auth-card, +.dialog-card, +.reaction-popup-card, +.media-card, +.header-menu-card, +.call-card { + width: min(100%, 400px); + padding: 24px; + border: 1px solid var(--line-strong); + border-radius: var(--radius-lg); + background: var(--surface); + box-shadow: var(--shadow); +} + +.auth-card h1, +.auth-card p { + text-align: center; +} + +.auth-card h1 { + margin: 0; + color: var(--text); + font-size: 25px; + font-weight: 750; + letter-spacing: -0.04em; +} + +.auth-card p { + margin: 8px 0 21px; + color: var(--muted); + line-height: 1.45; +} + +.auth-help { + margin: 14px 3px 0 !important; + font-size: 11px; +} + +.field { + min-height: 46px; + margin: 0 0 10px; + padding: 11px 13px; +} + +.auth-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px; +} + +.button.secondary { + color: var(--muted); +} + +.notice { + display: none; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: var(--radius-sm); + font-size: 13px; + line-height: 1.35; +} + +.notice.show { + display: block; +} + +.notice.error { + border: 1px solid rgba(255, 160, 155, 0.22); + background: var(--danger-bg); + color: var(--danger-text); +} + +/* Header menu / settings */ + +.header-menu-overlay { + z-index: 55; + align-items: flex-start; + justify-content: flex-end; + padding-top: calc(var(--safe-top) + 58px); + padding-right: max(18px, calc(var(--safe-right) + 18px)); + background: transparent; + backdrop-filter: none; + -webkit-backdrop-filter: none; + pointer-events: none; +} + +.header-menu-overlay:not(.hidden) { + pointer-events: auto; +} + +.header-menu-card { + width: min(320px, calc(100vw - 24px)); + padding: 10px; + border-radius: 16px; +} + +.header-menu-title { + padding: 8px 10px 10px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.header-menu-list { + display: grid; + gap: 4px; +} + +.header-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 42px; + width: 100%; + padding: 0 12px; + border: 0; + border-radius: 11px; + background: transparent; + color: var(--text-soft); + text-align: left; +} + +.header-menu-item:hover, +.header-menu-item:focus-visible { + background: var(--surface-hover); + color: var(--text); +} + +.header-menu-item small { + color: var(--muted); +} + +/* Settings view */ + +.settings-card { + border: 1px solid var(--line); + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-soft); +} + +.settings-section { + padding: 18px; + border-bottom: 1px solid var(--line); +} + +.settings-section:last-child { + border-bottom: 0; +} + +.settings-section h2 { + margin: 0 0 6px; + font-size: 18px; + font-weight: 800; +} + +.settings-section p { + margin: 0 0 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.settings-row { + display: grid; + gap: 10px; +} + +.settings-row.inline { + grid-template-columns: 1fr auto; + align-items: center; +} + +.settings-custom-css { + min-height: 180px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + resize: vertical; +} + +.settings-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.name-color-preview { + font-size: 16px; + font-weight: 800; +} + +/* Team/room management cards */ + +.manager-list { + display: grid; + gap: 10px; +} + +.manager-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--surface-raised); +} + +.manager-card-title { + color: var(--text); + font-size: 14px; + font-weight: 800; +} + +.manager-card-subtitle { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.manager-card-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +/* Admin */ + +.admin-fab { + position: fixed; + right: max(12px, calc(var(--safe-right) + 12px)); + bottom: calc(74px + var(--safe-bottom)); + z-index: 20; + display: none; + width: 42px; + height: 42px; + border: 1px solid var(--line); + border-radius: 50%; + outline: none; + background: var(--surface-raised); + color: var(--muted); + font-size: 18px; + box-shadow: var(--shadow-soft); +} + +.admin-fab.visible { + display: block; +} + +.admin-fab:hover, +.admin-fab:focus-visible { + border-color: var(--line-strong); + background: var(--surface-hover); + color: var(--text); +} + +.admin-panel { + position: fixed; + inset: 0; + z-index: 30; + display: none; + align-items: flex-end; + justify-content: center; + background: rgba(0, 0, 0, 0.74); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); +} + +.admin-panel.open { + display: flex; +} + +.admin-sheet { + display: flex; + flex-direction: column; + width: min(860px, 100%); + max-height: 85dvh; + padding: 18px; + overflow: hidden; + border: 1px solid var(--line-strong); + border-bottom: 0; + border-radius: 18px 18px 0 0; + background: var(--surface); + box-shadow: 0 -12px 42px rgba(0, 0, 0, 0.42); +} + +.admin-sheet-header, +.admin-actions { + display: flex; + align-items: center; + justify-content: space-between; + flex: 0 0 auto; + gap: 10px; +} + +.admin-actions { + flex-wrap: wrap; + margin: 15px 0; +} + +.admin-users, +.admin-rooms { + min-width: 0; +} + +.admin-users { + flex: 1 1 auto; + overflow: auto; + padding-bottom: 16px; +} + +.admin-user, +.admin-room { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--line); +} + +.admin-user-name, +.admin-room-name { + overflow: hidden; + color: var(--text-soft); + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-info, +.admin-room-info, +.admin-empty { + margin-top: 3px; + color: var(--muted); + font-size: 11px; +} + +.admin-user-actions, +.admin-room-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 5px; +} + +/* Dialogs */ + +.dialog-card { + width: min(100%, 500px); +} + +.dialog-card h2 { + margin: 0 0 12px; + color: var(--text); + font-size: 19px; + font-weight: 750; + letter-spacing: -0.03em; +} + +.dialog-body p { + margin: 0 0 14px; + color: var(--muted); + line-height: 1.45; +} + +.dialog-label { + display: block; + margin-top: 10px; + color: var(--text-soft); + font-size: 13px; + font-weight: 650; +} + +.dialog-label .field { + margin: 6px 0 0; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 9px; + margin-top: 18px; +} + +.member-list { + max-height: 220px; + margin-bottom: 12px; + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius-sm); +} + +.member-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px; + border-bottom: 1px solid var(--line); + color: var(--text-soft); + font-size: 13px; +} + +.member-row:last-child { + border-bottom: 0; +} + +/* Accent color picker */ + +.accent-picker { + margin-top: 4px; +} + +.accent-picker-description { + margin: 0 0 14px; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.accent-picker-label { + display: block; + color: var(--text-soft); + font-size: 13px; + font-weight: 700; +} + +.accent-picker-row { + display: flex; + align-items: center; + margin-top: 7px; + gap: 9px; +} + +.accent-color-input { + width: 48px; + height: 44px; + flex: 0 0 48px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: var(--radius-sm); + outline: none; + background: var(--surface-raised); + cursor: pointer; +} + +.accent-color-input:hover, +.accent-color-input:focus-visible { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-soft); +} + +.accent-hex-input { + min-width: 0; + flex: 1 1 auto; + margin: 0; + text-transform: lowercase; +} + +.accent-preview { + width: 34px; + height: 34px; + flex: 0 0 34px; + border: 1px solid rgba(255, 255, 255, 0.30); + border-radius: 50%; + background: var(--accent); + box-shadow: inset 0 0 0 3px rgba(0, 0, 0, 0.16); +} + +.accent-picker-error { + min-height: 18px; + margin-top: 7px; + color: var(--danger-text); + font-size: 12px; + line-height: 1.35; +} + +/* Reactions popup */ + +.reaction-popup-overlay { + z-index: 70; +} + +.reaction-popup-card { + width: min(440px, 100%); +} + +.reaction-popup-title { + margin: 0 0 10px; + font-size: 18px; + font-weight: 800; + color: var(--text); +} + +.reaction-popup-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 8px; +} + +.reaction-popup-button { + min-height: 48px; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--surface-raised); + color: var(--text); + font-size: 24px; +} + +.reaction-popup-button:hover, +.reaction-popup-button:focus-visible { + border-color: var(--accent); + background: var(--accent-soft); +} + +.reaction-popup-custom { + display: grid; + grid-template-columns: 1fr auto; + gap: 8px; + margin-top: 12px; +} + +.reaction-custom-input { + margin: 0; +} + +.reaction-popup-list { + max-height: 220px; + margin-top: 14px; + overflow: auto; + border-top: 1px solid var(--line); + padding-top: 10px; +} + +.reaction-popup-user { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 34px; + padding: 0 2px; + color: var(--text-soft); + font-size: 13px; +} + +/* Media modal */ + +.media-overlay { + z-index: 80; + padding: 18px; +} + +.media-card { + width: min(1100px, 100%); + max-height: calc(100dvh - 36px); + padding: 14px; +} + +.media-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; +} + +.media-title { + min-width: 0; + color: var(--text-soft); + font-size: 13px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} + +.media-body { + display: flex; + align-items: center; + justify-content: center; + min-height: 180px; +} + +.media-body img, +.media-body video { + display: block; + max-width: 100%; + max-height: calc(100dvh - 130px); + border-radius: 12px; + object-fit: contain; + background: var(--surface-raised); +} + +.media-body audio { + width: min(100%, 640px); +} + +.media-file-fallback { + display: grid; + gap: 12px; + justify-items: center; + text-align: center; +} + +/* Call overlay */ + +.call-overlay { + z-index: 85; +} + +.call-card { + width: min(100%, 460px); +} + +.call-title-row { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.call-title { + font-size: 18px; + font-weight: 800; + color: var(--text); +} + +.call-status { + margin-top: 4px; + color: var(--muted); + font-size: 12px; +} + +.call-peer-label { + margin-top: 16px; + color: var(--text-soft); + font-size: 14px; + font-weight: 700; +} + +.call-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 18px; +} + +/* Toasts and live status */ + +.new-messages-bar { + position: fixed; + right: 50%; + bottom: calc(86px + var(--safe-bottom)); + z-index: 11; + transform: translateX(50%); +} + +.typing-bar { + position: fixed; + left: max(18px, calc(var(--safe-left) + 18px)); + bottom: calc(79px + var(--safe-bottom)); + z-index: 15; + overflow: hidden; + color: var(--muted); + font-size: 12px; + font-style: italic; + pointer-events: none; +} + +.status-toast { + position: fixed; + left: 50%; + bottom: calc(84px + var(--safe-bottom)); + z-index: 60; + max-width: calc(100vw - 32px); + padding: 10px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-sm); + background: var(--surface-raised); + box-shadow: var(--shadow-soft); + color: var(--text); + font-size: 13px; + text-align: center; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 12px); + transition: opacity 180ms ease, transform 180ms ease; +} + +.status-toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +.status-toast.error { + border-color: rgba(255, 145, 140, 0.28); + background: var(--danger-bg); + color: var(--danger-text); +} + +@keyframes message-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (hover: none), (pointer: coarse) { + .message-actions { + opacity: 1; + pointer-events: auto; + transform: none; + } + + .message-bubble { + max-width: 72%; + } +} + +@media (max-width: 700px) { + .chat-header { + min-height: calc(56px + var(--safe-top)); + padding-right: max(12px, var(--safe-right)); + padding-left: max(12px, var(--safe-left)); + } + + .header-inner { + height: 56px; + gap: 7px; + } + + .header-account { + display: none; + } + + .header-subtitle { + display: none; + } + + .header-navigation { + gap: 2px; + } + + .feed-button { + padding-right: 6px; + padding-left: 6px; + } + + .room-select { + max-width: 130px; + padding-left: 6px; + } + + .team-select { + max-width: 105px; + } + + .channel-select { + max-width: 115px; + } + + .presence-label { + display: none !important; + } + + .chat-box, + .feed-box, + .thread-box, + .pages-box, + .page-detail-box, + .profile-box, + .dm-box { + padding: + 14px + max(12px, calc(var(--safe-left) + 12px)) + 14px + max(12px, calc(var(--safe-right) + 12px)); + } + + .message-row { + margin: 6px 0; + } + + .message-bubble { + max-width: 84%; + padding: 9px 12px; + } + + .message-meta { + max-width: 84%; + } + + .feed-entry { + margin: 8px 0; + padding: 12px; + } + + .composer-wrap { + padding: + 9px + max(10px, calc(var(--safe-right) + 10px)) + calc(8px + var(--safe-bottom)) + max(10px, calc(var(--safe-left) + 10px)); + } + + .typing-bar { + left: max(12px, calc(var(--safe-left) + 12px)); + bottom: calc(75px + var(--safe-bottom)); + max-width: calc(100vw - 24px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .admin-sheet { + width: 100%; + max-height: 88dvh; + padding: 14px; + } + + .admin-user, + .admin-room, + .manager-card { + grid-template-columns: 1fr; + } + + .admin-user-actions, + .admin-room-actions, + .manager-card-actions { + justify-content: flex-start; + } + + .pages-header { + align-items: stretch; + flex-direction: column; + } + + .pages-header .button { + width: 100%; + } + + .pages-list { + grid-template-columns: 1fr; + } + + .page-card { + min-height: 0; + } + + .page-body { + padding: 15px; + } + + .reaction-popup-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .profile-header { + align-items: flex-start; + flex-direction: column; + } + + .reaction-popup-custom { + grid-template-columns: 1fr; + } + + .call-actions { + flex-direction: column; + } + + .call-actions > * { + width: 100%; + } +} + +@media (max-width: 390px) { + .header-account-wrap { + display: none; + } + + .header-navigation { + flex: 1 1 auto; + justify-content: flex-end; + } + + .room-select { + flex: 1 1 auto; + width: auto; + max-width: 118px; + } + + .team-select { + max-width: 82px; + } + + .channel-select { + max-width: 95px; + } + + .pages-button, + #backButton { + padding-right: 5px; + padding-left: 5px; + } + + .message-bubble { + max-width: 89%; + } + + .auth-actions { + grid-template-columns: 1fr; + } + + .dialog-actions { + flex-direction: column-reverse; + } + + .dialog-actions > * { + width: 100%; + } + + .accent-picker-row { + flex-wrap: wrap; + } + + .accent-hex-input { + min-width: calc(100% - 57px); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +.load-older-wrap { + display: flex; + justify-content: center; + margin: 4px 0 16px; +} + +.message-edited { + margin-top: 6px; + color: var(--muted); + font-size: 11px; + font-style: italic; +} + +.message-attachments-list { + display: grid; + gap: 8px; + margin-top: 10px; +} + +.reaction-popup-section + .reaction-popup-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--line); +} + +.markdown-body h1, +.markdown-body h2, +.markdown-body h3, +.profile-rendered h1, +.profile-rendered h2, +.profile-rendered h3 { + margin: 0.8em 0 0.4em; +} + +.markdown-body p, +.profile-rendered p { + margin: 0.5em 0; +} + +.markdown-body pre, +.profile-rendered pre { + padding: 12px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-raised); +} + +.markdown-body code, +.profile-rendered code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} + +.markdown-body blockquote, +.profile-rendered blockquote { + margin: 0.8em 0; + padding-left: 12px; + border-left: 3px solid var(--accent); + color: var(--muted); +} + +.markdown-body ul, +.markdown-body ol, +.profile-rendered ul, +.profile-rendered ol { + padding-left: 20px; +} + +.composer-wrap.dragover { + box-shadow: inset 0 0 0 2px var(--accent); +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..28a762b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,539 @@ + + + + + + + + + + pre.v1 + + + +
+
+
+
+ + +
+
Feed
+
Recent activity
+
+
+ +
+ + + + + + + + + + + + + + + +
+ + +
+
+ +
+
+
Loading activity…
+
+
+ +
+
+
Sign in to view direct messages.
+
+
+ +
+ +
+ +
+ +
+ +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + +