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:
┌──────────────────────────────────────────────────────────────┐
│ 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:
Variable |
Purpose |
|---|---|
|
The currently active channel or DM identifier string. |
|
Timestamp of the last message received; used as the |
|
|
|
|
|
Injected from the Jinja2 template: |
|
Boolean loaded from |
|
The state most recently sent to |
|
Timestamp of the last observed keyboard/mouse event (for idle detection). |
|
Boolean flag — prevents sending repeated |
|
Timer handle used to detect the |
|
Object mapping username → hex colour string, populated from
|
|
|
|
Most recent result of |
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.
Function |
Interval |
Description |
|---|---|---|
|
500 ms |
Core polling loop. Calls |
|
1 s |
Calls |
|
1 s |
Calls |
|
1 s |
Calls |
|
1 s |
Calls |
|
3 s |
Calls |
|
5 s |
Calls |
|
1 s |
Calls |
|
3 s |
During an active call: diffs the participant list (opening/closing
|
WebRTC signalling poll ( |
600 ms |
Drains |
Presence heartbeat |
30 s |
Re-sends the current presence state to keep |
Idle check |
5 s |
Compares |
Message Rendering
fetchMessages() is the heart of the client. On each call it:
Calls
GET /messages/<channel>?after=<lastTs>.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),editedorreactionschanged): finds the existing DOM element and patches the content.Deleted message: replaces the message content with a
[message deleted]tombstone and hides action buttons.
Updates
lastTsto the highesttsvalue seen.Scrolls to the bottom if the user was already at the bottom before the update.
Triggers desktop and sound notifications for new messages when the tab is hidden, and for
@mentionsof the current user even when the tab is focused (see Mentions).
Message DOM Structure
Each message is rendered as:
<div class="msg" data-id="42" data-ts="1716000000.123" data-sender="alice">
<div class="msg-header">
<span class="user">alice</span>
<span class="time">12:34</span>
<div class="msg-actions">
<!-- Reply, Edit, Delete buttons (conditionally shown) -->
</div>
</div>
<div class="text">Hello, world!</div>
<!-- OR for images: -->
<img class="chat-image" src="/files/uuid.jpg" loading="lazy">
<!-- Optional: -->
<div class="reply-quote">...</div>
<div class="reactions">...</div>
<div class="link-preview">...</div>
<div class="read-receipt">✓ Read by alice, bob</div>
</div>
Text Formatting
formatText(text) converts a subset of Markdown to HTML:
Input |
Output |
Notes |
|---|---|---|
|
|
Bold |
|
|
Italic |
|
|
Underline (non-standard Markdown extension) |
|
|
Strikethrough |
|
|
Auto-linked URLs ( |
|
|
Mention pill, rendered only for known users and |
All text is HTML-escaped before formatting is applied to prevent XSS.
Keyboard Shortcuts
See 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.messagescontainer 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:
User clicks the
😊button on a message (or hovers to reveal it).openReactionPicker(msgId)renders a modal grid of all available emoji.User clicks an emoji in the picker;
pickerReact(name)is called.toggleReaction(msgId, name)POSTs to/react/<msgId>.The server response contains the updated reactions map.
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
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:
activeMentionToken()inspects the text before the caret and returns the@tokenbeing typed (start offset and partial query), ornull.ensureMentionMembers()lazily fetchesGET /channel_members/<channel>and caches the result per channel. The reserved keywordeveryoneis always offered alongside the real members.refreshMentions()ranks candidates with the sharedfuzzySearchand renders them (avatar + name, or an@badge and hint foreveryone). A capture-phasekeydownhandler runs before the send-on-Enter handler so↑/↓navigate,Enter/Tabaccept, andEsccloses.acceptMention()replaces the@tokenwith@username(or@everyone) plus a trailing space.
Pill rendering. applyMentionPills(html) (called from formatText)
wraps @username tokens for known users in <span class="mention"> 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
mentionedCSS class (a highlighted, left-barred message), andplays the new-message sound (honoring
notifMuted) and firesnotifyMention()— a nativeNotificationhonoringnativeNotifEnabledand 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:
Server-side search —
GET /search_messages?q=<query>performs a SQLiteLIKE %query%match and returns up to 50 results. This handles exact substring matches across the full message history.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 <mark> 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:
Visibility —
document.addEventListener("visibilitychange", ...)sends"hidden"when the tab moves to the background and"active"when it returns.Activity — mousemove and keydown events reset
lastActivity. The idle-check interval (5 s) comparesDate.now() - lastActivityto 5 minutes (300,000 ms); if exceeded,"idle"is sent andidleSentis set to prevent repeated idle notifications.Heartbeat — every 30 seconds, the current
currentPresenceis re-sent to keeplast_seenfrom expiring inpresence.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 aNotificationwhendocument.hiddenis 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:
File picker — clicking the
📎button opens a file input.Drag and drop — files dragged onto the message input area are captured by
dragoveranddropevent listeners.Clipboard paste —
pasteevents on the input box checkevent.clipboardData.itemsfor 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:
Extracts the first URL from the message text.
Calls
GET /link_preview?url=<url>.If
type === "og",_previewOgEl(data)renders an OpenGraph card.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:
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:
The selected file is drawn into a
<canvas>element.The canvas is centre-cropped to a square and scaled to 128 × 128 px.
canvas.toBlob("image/jpeg", 0.88)produces the compressed image.The blob is sent to
POST /avatarasmultipart/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.
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/<id>/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:
User starts typing in the input box.
fuzzySearchfilters theallUserslist (loaded from/users).Suggestions are shown in a dropdown; arrow keys navigate, Enter/Tab selects.
Multiple users can be entered (comma-separated) for group DMs.
On submit,
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 Architecture).
ICE configuration:
RTC_CONFIG points ICE at the app’s own STUN server using the page’s
hostname (stun:<location.hostname>:<STUN_PORT>, 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:
The microphone is acquired via
getUserMediabefore 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.startCall()posts{ channel }toPOST /calls/initiateand receives acall_id.A call panel appears;
callingAudio(calling.mp3) loops while waiting for the first participant to answer.A 30-second
setTimeout(ringTimeoutId) is armed; if nobody answers in time,_handleRingTimeout()postsPOST /calls/<id>/endand shows “No answer” before cleaning up._startCallStatePolling()pollsGET /calls/<id>/stateevery 3 seconds. Whenstate === "active"the ring timeout is cleared andcall_accepted.mp3plays._startCallSignaling()begins a 600 ms poll ofGET /calls/<id>/signals. As participants are seen asaccepted(via the state poll) or a signal arrives from them, aRTCPeerConnectionis created, the local microphone track is added, and offers/answers/ICE candidates are exchanged throughPOST /calls/<id>/signal.
Callee / invite flow:
pollIncomingCalls()runs every second, pollingGET /calls/incoming. This endpoint returns both fresh ringing calls and active-call invitations (a pending participant row on an active call).When a call is returned,
openIncomingCallUI(callData)shows an overlay with the caller’s name and Accept / Decline buttons;receiving_call.mp3loops. The client-side ring timeout is always the fullRING_TIMEOUT_MS(30 seconds) regardless of how long the call has been ringing on the server, so invited users always get the full window.Accepting posts to
POST /calls/<id>/accept; declining posts toPOST /calls/<id>/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:
Checks the overall call state.
"ended"or"rejected"triggers_cleanupCall()and playshang_up.mp3.Diffs the accepted participant list against
remoteParticipants(aMap<username, state>). New accepted participants get a tile added via_addRemoteParticipant(); departed participants are removed via_removeRemoteParticipant(), which also playsleft_call.mp3while the call is still active.Reads
screenshare_userfrom 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
ontrackevent and is attached to a hidden<audio>element for playback.An
AnalyserNodetapped off that remote stream drives a 100 ms VAD interval that toggles thespeakingCSS class on the participant’s tile ring.
Local microphone meter:
_startMicLevelMeter() taps the local getUserMedia track through its own
AudioContext + AnalyserNode and updates the #call-mic-level fill on
the mute button every 50 ms, giving the user immediate “my mic works” feedback
(independent of the WebRTC connection). It resumes a suspended AudioContext
(and retries on the next gesture) and logs the chosen input device’s
label/muted/readyState to aid debugging silent-microphone issues.
Participant tile grid:
_createParticipantTile(username) builds a DOM tile containing a speaking
ring (div.call-speaking-ring inside div.call-participant-avatar),
an avatar (makeAvatarWrap), and a name label. The ring uses
inset: -6px on the avatar container so it is always perfectly centred
regardless of tile size. _updateCallGrid() sets the CSS grid
grid-template-columns based on the number of remote participants:
1 participant:
1fr(centred, full width).2 or more participants:
1fr 1fr(equal columns).
Inviting participants during a call:
The “Add person” button in the call controls bar opens a panel with a fuzzy-
search input (backed by fuzzySearch / highlightFuzzyMatch) that lists
all registered users not already in the call. Clicking a name posts to
POST /calls/<id>/invite, which adds a call_participants row with
state = 'pending'. The invited user sees the standard incoming-call
notification; the call is already active so they join immediately on accept.
Leave vs. end:
Clicking the hang-up button calls endCall(), which posts to
POST /calls/<id>/end. The server marks the participant as 'left' and
only transitions the overall call to 'ended' if no other accepted
participants remain. Participants still in the call detect the departure on
their next _pollCallState tick (the departed user’s entry changes to
state: "left"), remove their tile, and play left_call.mp3.
In-call screen sharing flow:
Screen sharing is available during an active call. Only one participant may
share at a time; the server tracks the current sharer in the screenshare_user
column of the calls table.
toggleScreenShare()is invoked directly by the button’sonclickhandler.navigator.mediaDevices.getDisplayMedia()is called immediately — without any intermediate async calls — so the browser’s user-gesture activation token is preserved.The display video track is added to every existing
RTCPeerConnectionviaaddTrack(), which triggers renegotiation (perfect negotiation).POST /calls/<id>/screensharerecords the sharer inscreenshare_user.On the receiver side the new video track arrives via
ontrackand is shown in#call-screen-video(thescreen-share-activeclass expands it to the main panel)._pollCallState()readsscreenshare_userto enforce the single-sharer policy and as a backup for tearing the view down.Stopping (button, native “stop sharing”, or leaving) removes the track (renegotiating) and clears
screenshare_user; the receiver hides the screen on the track’sended/muteevent.
Standalone screen sharing (outside a call):
The topbar share button (toggleStandaloneScreenShare()) starts a share in
any DM/private channel via POST /screenshare/start. This is
viewer-initiated: other members see a banner (refreshScreenShares()
polls GET /screenshare/active) and click View to open
openShareViewer(), which creates a recvonly RTCPeerConnection, sends an
offer to the sharer, and renders the answered track. The sharer polls
GET /screenshare/<id>/signals and, for each viewer offer, attaches its
screen track (via replaceTrack on the offered transceiver) and answers —
fanning out one sharer to many viewers. ICE candidates are buffered until the
remote description is set so none are dropped to a signalling race.
Sound effects:
All call audio respects the notification mute toggle.
File |
When played |
|---|---|
|
Loops on the callee side while the incoming-call overlay is visible. |
|
Loops on the caller side while waiting for the first answer. |
|
Played once on the caller side when |
|
Played when the local user hangs up, when the call ends for all
participants (detected via |
|
Played when a remote participant leaves while the call is still active. |
Key variables:
Variable |
Purpose |
|---|---|
|
UUID of the call currently in progress; |
|
|
|
ICE configuration — a single |
|
Cursor ( |
|
|
|
Username of the participant whose screen video is currently being
received; |
|
Handle for the caller-side 30-second ring timeout. |
|
Handle for the callee-side ring timeout (always |
|
Ring timeout in milliseconds (30 000); matches the backend
|
|
The call object currently shown in the incoming-call overlay. |
|
The |
|
The |
|
|
|
The active standalone share id and the map of viewer username →
|
|
The viewer-side |
Mobile Support
The sidebar is hidden by default on narrow screens and revealed by a
hamburger menu button. The touch-action: manipulation CSS property
disables double-tap zoom on buttons for a more app-like feel. Pinch-to-zoom
adjusts the message font size, and this preference is saved in
localStorage['fontSize'].