Frontend Architecture ===================== MiniMost's chat interface is a **single-page application (SPA)** implemented in vanilla JavaScript — no framework, no build step, no bundler. This page documents the client-side architecture for developers who need to understand or modify the frontend behaviour. Page Structure -------------- The rendered HTML has three main regions: .. code-block:: text ┌──────────────────────────────────────────────────────────────┐ │ Sidebar (220–260px) │ Main Content (flex: 1) │ │ ───────────────────────── │ ───────────────────────────── │ │ Public Channels │ Topbar │ │ ● general [3] │ │ │ ● software │ Chat Area (scrollable) │ │ ● off-topic │ ── Date divider ── │ │ │ alice 12:34 │ │ Private Channels │ Hello, world! │ │ ● minimost-enjoyers [5] │ │ │ ● software │ bob 12:35 │ │ │ 👋 Hello! │ │ Direct Messages │ │ │ ● bob [1] │ │ │ ● charlie │ Typing indicator │ │ │ ───────────────────────────── │ │ │ [ Type a message... ] │ │ │ [ 📎 ] [ ▶ Send ] │ └──────────────────────────────────────────────────────────────┘ Client-side State ----------------- Key JavaScript variables maintained in the module scope: .. list-table:: :header-rows: 1 :widths: 25 75 * - Variable - Purpose * - ``channel`` - The currently active channel or DM identifier string. * - ``lastTs`` - Timestamp of the last message received; used as the ``after`` parameter for incremental polling. * - ``unread`` - ``Set`` of message IDs considered unread in the current view. * - ``seen`` - ``Set`` of message IDs already rendered (deduplication guard). * - ``CURRENT_USER`` - Injected from the Jinja2 template: ``{{ session.user }}``. * - ``notifMuted`` - Boolean loaded from ``localStorage['notifMuted']``; persists across page reloads. * - ``currentPresence`` - The state most recently sent to ``/presence``. * - ``lastActivity`` - Timestamp of the last observed keyboard/mouse event (for idle detection). * - ``idleSent`` - Boolean flag — prevents sending repeated ``"idle"`` presence updates. * - ``ggTimer`` - Timer handle used to detect the ``gg`` (jump-to-top) double-keypress sequence; cleared after the timeout window expires. * - ``userColorOverrides`` - Object mapping username → hex colour string, populated from ``GET /user_colors`` on sidebar load. * - ``usersWithAvatars`` - ``Set`` of usernames that have a custom avatar, populated from ``GET /user_avatars`` on sidebar load. * - ``presenceMapCache`` - Most recent result of ``GET /online_users``; used to initialise presence dots on newly created avatar elements. Polling Loops ------------- The client starts several ``setInterval`` loops after the page loads. Each loop calls a different API endpoint and updates the DOM based on the response. .. list-table:: :header-rows: 1 :widths: 30 15 55 * - Function - Interval - Description * - ``fetchMessages()`` - 500 ms - Core polling loop. Calls ``/messages/?after=`` and merges new/updated/deleted messages into the chat area. * - ``refreshPresence()`` - 1 s - Calls ``/online_users`` and updates presence dots in the sidebar. * - ``fetchTyping()`` - 1 s - Calls ``/typing/`` and shows/hides the typing indicator. * - ``refreshDMs()`` - 1 s - Calls ``/dms`` and refreshes the DM list with current unread counts. * - ``refreshChannels()`` - 1 s - Calls ``/channel_unreads`` and updates channel unread badges. * - ``fetchReadReceipts()`` - 3 s - Calls ``/read_receipts/`` and updates ``✓`` indicators. * - ``refreshTotalUnreadCount()`` - 5 s - Calls ``/unread_count`` and updates the browser tab title. * - ``pollIncomingCalls()`` - 1 s - Calls ``/calls/incoming`` and surfaces incoming call notifications for both ringing calls and active-call invitations. * - ``_pollCallState()`` - 3 s - During an active call: diffs the participant list (opening/closing ``RTCPeerConnection`` s), detects call end, and tracks ``screenshare_user`` changes. Started by ``startCall()`` and ``acceptCall()``; stopped by ``_cleanupCall()``. * - WebRTC signalling poll (``_pollCallSignals``) - 600 ms - Drains ``/calls//signals`` and dispatches each offer/answer/ICE candidate to the matching peer connection (perfect negotiation). Standalone screen shares poll ``/screenshare//signals`` the same way. Call and screen **media** travels peer-to-peer and is never polled. * - Presence heartbeat - 30 s - Re-sends the current presence state to keep ``last_seen`` fresh. * - Idle check - 5 s - Compares ``Date.now() - lastActivity`` to 5 minutes; sends ``"idle"`` if exceeded. Message Rendering ----------------- ``fetchMessages()`` is the heart of the client. On each call it: 1. Calls ``GET /messages/?after=``. 2. For each returned message: - **New message** (``!seen.has(id)``): creates a DOM element and appends it to the chat area. Inserts date-divider banners when the date changes between messages. - **Updated message** (``seen.has(id)``, ``edited`` or ``reactions`` changed): finds the existing DOM element and patches the content. - **Deleted message**: replaces the message content with a ``[message deleted]`` tombstone and hides action buttons. 3. Updates ``lastTs`` to the highest ``ts`` value seen. 4. Scrolls to the bottom if the user was already at the bottom before the update. 5. Triggers desktop and sound notifications for new messages when the tab is hidden, and for ``@mentions`` of the current user even when the tab is focused (see :ref:`mentions`). Message DOM Structure ~~~~~~~~~~~~~~~~~~~~~ Each message is rendered as: .. code-block:: html
alice 12:34
Hello, world!
...
...
✓ Read by alice, bob
Text Formatting --------------- ``formatText(text)`` converts a subset of Markdown to HTML: .. list-table:: :header-rows: 1 :widths: 30 20 50 * - Input - Output - Notes * - ``**text**`` - ``text`` - Bold * - ``*text*`` - ``text`` - Italic * - ``__text__`` - ``text`` - Underline (non-standard Markdown extension) * - ``~~text~~`` - ``text`` - Strikethrough * - ``https://...`` - ``...`` - Auto-linked URLs (``http`` and ``https`` only) * - ``@username`` - ``@username`` - Mention pill, rendered only for known users and ``@everyone`` (see :ref:`mentions`). Applied after link auto-linking so URLs containing ``@`` are left untouched. All text is HTML-escaped before formatting is applied to prevent XSS. Keyboard Shortcuts ------------------ See :doc:`keyboard_shortcuts` for the complete reference. Global keyboard events are handled by ``userInput(e)``. Notable implementation details: - ``Ctrl+B/I/U/S`` — format shortcuts wrap the selected text or toggle prefix/suffix markers at the cursor position. - Navigation shortcuts (``j/k/d/u/G/g``) scroll the ``.messages`` container by a fixed number of pixels. - ``Ctrl+J/Ctrl+K`` — cycle through the channel list by finding the current channel's index in the sidebar and activating the next/previous sibling. Reactions --------- The emoji reaction system uses 477 reactions defined in the ``REACTIONS`` array inside ``chat.html``. Each entry carries a ``name``, ``label``, and ``emoji`` (Unicode character). The ``REACTION_EMOJI`` lookup map (built from the array) provides O(1) name-to-character access. The workflow: 1. User clicks the ``😊`` button on a message (or hovers to reveal it). 2. ``openReactionPicker(msgId)`` renders a modal grid of all available emoji. 3. User clicks an emoji in the picker; ``pickerReact(name)`` is called. 4. ``toggleReaction(msgId, name)`` POSTs to ``/react/``. 5. The server response contains the updated reactions map. 6. ``buildReactionsHtml(msgId, reactions)`` re-renders the reaction chips below the message. Each reaction chip shows the emoji character and a count. Hovering reveals a tooltip with the list of reactor usernames. .. _mentions: Mentions -------- The ``@``-mention system lives in ``chat-mentions.js`` and has three parts. **Autocomplete dropdown.** Typing ``@`` in the composer opens a fuzzy-search dropdown of the current channel's members: 1. ``activeMentionToken()`` inspects the text before the caret and returns the ``@token`` being typed (start offset and partial query), or ``null``. 2. ``ensureMentionMembers()`` lazily fetches ``GET /channel_members/`` and caches the result per channel. The reserved keyword ``everyone`` is always offered alongside the real members. 3. ``refreshMentions()`` ranks candidates with the shared ``fuzzySearch`` and renders them (avatar + name, or an ``@`` badge and hint for ``everyone``). A capture-phase ``keydown`` handler runs before the send-on-Enter handler so ``↑``/``↓`` navigate, ``Enter``/``Tab`` accept, and ``Esc`` closes. 4. ``acceptMention()`` replaces the ``@token`` with ``@username`` (or ``@everyone``) plus a trailing space. **Pill rendering.** ``applyMentionPills(html)`` (called from ``formatText``) wraps ``@username`` tokens for known users in ```` pills, using a brighter ``mention-me`` variant for the current user and ``@everyone``. Known users come from a ``GET /users`` fetch at load plus every channel-members fetch. Tokens preceded by a word character, ``@`` or ``/`` are skipped, so emails and URLs never become pills. **Highlighting and notifications.** Mentions are extracted and validated server-side and returned in each message's ``mentions`` field (a JSON array, or the ``"@everyone"`` sentinel). ``isMentioned(m)`` is true when the current user appears there — or for ``@everyone`` on any copy except the sender's own. ``fetchMessages()`` then: - toggles the ``mentioned`` CSS class (a highlighted, left-barred message), and - plays the new-message sound (honoring ``notifMuted``) and fires ``notifyMention()`` — a native ``Notification`` honoring ``nativeNotifEnabled`` and browser permission — **even when the tab is focused**. A ``mentionNotifyArmed`` flag, reset on page load and channel switch and armed after the first poll response, suppresses these alerts for the historical mentions in a channel's initial backlog. Search ------ The search modal uses two matching strategies: 1. **Server-side search** — ``GET /search_messages?q=`` performs a SQLite ``LIKE %query%`` match and returns up to 50 results. This handles exact substring matches across the full message history. 2. **Client-side fuzzy search** — ``fuzzySearch(query, text)`` scores candidate strings based on how well a fuzzy (out-of-order character) match works. Used to rank results and highlight matches in the UI. Results are displayed with the matched text highlighted using ``highlightFuzzyMatch(text, indices)``, which wraps matched character positions in ```` tags. The search input is debounced (250 ms) to avoid sending a request on every keypress. Presence System --------------- The client tracks three signals to determine presence state: 1. **Visibility** — ``document.addEventListener("visibilitychange", ...)`` sends ``"hidden"`` when the tab moves to the background and ``"active"`` when it returns. 2. **Activity** — mousemove and keydown events reset ``lastActivity``. The idle-check interval (5 s) compares ``Date.now() - lastActivity`` to 5 minutes (300,000 ms); if exceeded, ``"idle"`` is sent and ``idleSent`` is set to prevent repeated idle notifications. 3. **Heartbeat** — every 30 seconds, the current ``currentPresence`` is re-sent to keep ``last_seen`` from expiring in ``presence.db``. Notifications ------------- Two notification channels are supported: - **Desktop notifications** — ``sendDesktopNotification(count)`` requests permission on first use (browsers require a user gesture for this) and sends a ``Notification`` when ``document.hidden`` is true and there are unread messages. - **Sound notifications** — a short beep is played using the Web Audio API when a new message arrives and the user has not muted notifications. The mute state is persisted in ``localStorage['notifMuted']``. The browser tab title is updated by ``updateTitleBadge(count)`` to show unread count: ``(3) MiniMost``. ``startFaviconFlash()`` alternates the favicon between the normal icon and a red dot every second while there are unread messages and the tab is in the background. File Upload ----------- Three input methods are supported for image attachments: 1. **File picker** — clicking the ``📎`` button opens a file input. 2. **Drag and drop** — files dragged onto the message input area are captured by ``dragover`` and ``drop`` event listeners. 3. **Clipboard paste** — ``paste`` events on the input box check ``event.clipboardData.items`` for image data. Files are accumulated in the ``pendingFiles`` array. ``addFiles(files)`` renders thumbnail previews above the input. On send, files are submitted as a ``multipart/form-data`` body using ``FormData``. Link Previews ------------- After rendering a message containing a URL, ``attachPreview(msgEl)`` is called asynchronously: 1. Extracts the first URL from the message text. 2. Calls ``GET /link_preview?url=``. 3. If ``type === "og"``, ``_previewOgEl(data)`` renders an OpenGraph card. 4. If ``type === "code"``, ``_previewCodeEl(data)`` renders a code block with ``_syntaxHighlight(text, ext)`` applied. ``_syntaxHighlight`` uses regex-based rules for these languages: ``python``, ``js``, ``c``, ``sh``, ``make``, ``cmake``, ``vhdl``, ``verilog``, ``java``, ``go``, ``rust``. Avatar System ------------- Every user has a circular avatar shown in three places: the DM sidebar entry, the private channel hover tooltip, and the channel member list modal. ``makeAvatarWrap(username, size, channelKey)`` creates the DOM structure: .. code-block:: text div.avatar-wrap (position: relative; width/height set inline) ├── div.avatar (border-radius: 50%; overflow: hidden) │ ├── img.avatar-img (if the user has a custom avatar) │ └── div.avatar-initials (fallback — first two letters of username) └── span.avatar-presence (presence dot, position: absolute, bottom-right) The presence dot carries a ``data-username`` attribute. ``refreshPresence()`` queries ``document.querySelectorAll(".avatar-presence[data-username]")`` once and updates every dot from the cached presence map in a single pass. When a user uploads a custom avatar the image is resized **client-side** using the Canvas API before the upload request is sent: 1. The selected file is drawn into a ```` element. 2. The canvas is centre-cropped to a square and scaled to 128 × 128 px. 3. ``canvas.toBlob("image/jpeg", 0.88)`` produces the compressed image. 4. The blob is sent to ``POST /avatar`` as ``multipart/form-data``. This means no server-side image library is required — the server stores whatever JPEG the client sends. Settings Modal -------------- The settings cog button (top-right, next to Logout) opens a modal with two sections: **Name colour** — a row of colour swatches plus a hex preview chip. Clicking a swatch immediately updates ``userColorOverrides[CURRENT_USER]`` in memory and re-renders the current user's name in the sidebar so the change is visible before saving. The chosen colour is written to ``POST /settings`` on save. **Avatar** — shows a 64 × 64 preview of the current avatar (initials or uploaded image). The user can pick a new image file; ``_resizeImage()`` runs the Canvas resize pipeline and stores the resulting blob in ``_pendingAvatarBlob``. A "Remove" button sets the ``_removeAvatar`` flag. On save, the pending upload/delete is executed before the colour setting is saved. DM Sidebar Close Button ----------------------- Each DM entry in the sidebar has a ``×`` close button that appears on hover. Clicking it calls ``closeDm(channelName)``, which posts to ``POST /dms/close`` and removes the entry from the DOM. The conversation is not deleted — it reappears as soon as a new message arrives in that thread. Private Channel Leave --------------------- The private channel controls bar (shown when a private channel is active) includes a **Leave** button. Clicking it calls ``leaveChannel()``, which posts to ``POST /private_channels//leave``. On success the channel is removed from the sidebar and the client switches to the first available channel. A system message is written to the channel informing remaining members that the user has left. DM Modal -------- The "New DM" modal provides username autocomplete: 1. User starts typing in the input box. 2. ``fuzzySearch`` filters the ``allUsers`` list (loaded from ``/users``). 3. Suggestions are shown in a dropdown; arrow keys navigate, Enter/Tab selects. 4. Multiple users can be entered (comma-separated) for group DMs. 5. On submit, :func:`minimost.chat.normalize_dm` is computed client-side and the user is switched to that channel. Calling ------- Voice calls (audio only) are initiated from a phone icon displayed in the topbar when the active channel is a DM or private channel. Calls support any number of participants; any member of an active call can invite additional registered users. Media travels **peer-to-peer over WebRTC** (``RTCPeerConnection``); the server only relays signalling and runs a bundled STUN server for LAN ICE (see :doc:`architecture`). **ICE configuration:** ``RTC_CONFIG`` points ICE at the app's own STUN server using the page's hostname (``stun::``, injected into the template as ``STUN_PORT``). No public STUN/TURN servers are used, so calls work on an air-gapped LAN. ``_logPeerState()`` logs the ICE connection state to the console and, on failure, hints at the likely cause (e.g. unreachable STUN UDP port or peers on different subnets). **Caller flow:** 1. The microphone is acquired via ``getUserMedia`` *before* the call is created on the server. This ensures the call is never created and then immediately abandoned due to a permission denial or browser timeout. 2. ``startCall()`` posts ``{ channel }`` to ``POST /calls/initiate`` and receives a ``call_id``. 3. A call panel appears; ``callingAudio`` (``calling.mp3``) loops while waiting for the first participant to answer. 4. A 30-second ``setTimeout`` (``ringTimeoutId``) is armed; if nobody answers in time, ``_handleRingTimeout()`` posts ``POST /calls//end`` and shows "No answer" before cleaning up. 5. ``_startCallStatePolling()`` polls ``GET /calls//state`` every 3 seconds. When ``state === "active"`` the ring timeout is cleared and ``call_accepted.mp3`` plays. 6. ``_startCallSignaling()`` begins a 600 ms poll of ``GET /calls//signals``. As participants are seen as ``accepted`` (via the state poll) or a signal arrives from them, a ``RTCPeerConnection`` is created, the local microphone track is added, and offers/answers/ICE candidates are exchanged through ``POST /calls//signal``. **Callee / invite flow:** 1. ``pollIncomingCalls()`` runs every second, polling ``GET /calls/incoming``. This endpoint returns both fresh ringing calls *and* active-call invitations (a pending participant row on an active call). 2. When a call is returned, ``openIncomingCallUI(callData)`` shows an overlay with the caller's name and Accept / Decline buttons; ``receiving_call.mp3`` loops. The client-side ring timeout is always the full ``RING_TIMEOUT_MS`` (30 seconds) regardless of how long the call has been ringing on the server, so invited users always get the full window. 3. Accepting posts to ``POST /calls//accept``; declining posts to ``POST /calls//reject``. **Group call — state polling and participant diffing:** ``_pollCallState()`` is the engine that keeps the call panel in sync with server state. On every tick it: 1. Checks the overall call state. ``"ended"`` or ``"rejected"`` triggers ``_cleanupCall()`` and plays ``hang_up.mp3``. 2. Diffs the accepted participant list against ``remoteParticipants`` (a ``Map``). New accepted participants get a tile added via ``_addRemoteParticipant()``; departed participants are removed via ``_removeRemoteParticipant()``, which also plays ``left_call.mp3`` while the call is still active. 3. Reads ``screenshare_user`` from the response and triggers screen-receive transitions (see below). **Peer connections, remote audio, and voice activity detection:** Each remote participant has one ``RTCPeerConnection`` (stored on its ``remoteParticipants`` entry) negotiated with the perfect-negotiation pattern (``polite = CURRENT_USER < username`` to break offer glare): - The remote audio track arrives via the connection's ``ontrack`` event and is attached to a hidden ``