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 |
Event Stream (chat-events.js)
Live updates arrive over a single EventSource connection opened by
connectEvents() (chat-events.js) instead of a fleet of setInterval
loops. Each named SSE event carries the same JSON a former poller fetched and is
handed to the same render function, now split out of its old fetcher so both the
one-shot REST load and the push path share it:
SSE event |
Render function |
Replaces poller |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The fetchers (fetchMessages, refreshDMs, …) still exist for one-shot
loads — initial sidebar build and the instant backlog paint on a channel switch
— but are no longer driven on an interval. switchChannel reconnects the
stream (fetchMessages().then(connectEvents)) so the message cursor and
channel scope follow the user.
Loops that remain on setInterval (all unrelated to the SSE stream):
Function |
Interval |
Description |
|---|---|---|
|
3 s |
During an active call only: diffs the participant list
(opening/closing |
WebRTC signalling poll ( |
600 ms |
During an active call only: 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) scroll the.messagescontainer by a fixed number of pixels;Gjumps to the bottom.ggis a real chord: the firstgarms a 500 ms timer and the second jumps to the top. Any other key in between abandons it, so a straygwhile reading scrollback no longer throws you to the oldest message.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.
Pinned Messages
Pinning lives in chat-pins.js. A pin belongs to the channel, not to the
user who set it, so the server is the only source of truth here and the module
never mutates its list locally. Every path — pinning, unpinning, or someone else
doing either — ends with the whole list arriving from the server and
applyPins() repainting from it:
The pin button in a message’s hover actions (or
pin visual mode) callstogglePin(msgId), which POSTs to/pin/<msgId>.The response is the channel’s full post-toggle list. Every other viewer gets the same payload as the
pinsSSE event, so nobody has to poll.applyPins(data, forChannel)stores it aspinnedMessagesplus apinsByIdMap, and drops any payload whose channel is no longer the open one — the guard that keeps an in-flight response from a channel the user has left off the screen.
Three surfaces render from that one payload:
The topbar button (
#pins-btn) carries a count and is hidden entirely at zero, since a control that can only report emptiness is noise.The dropdown panel (
#pins-panel) lists each pin, newest-pinned first; clicking a row calls the sharedrevealMessage()— the same jump search results and mentions use — and each row carries an×to unpin without hunting for the message first.The transcript itself, via
paintPinMarkers(), which toggles thepinnedclass and inserts aPinned by <user>caption into.msg-content-col. The caption goes there rather than in the message header because a grouped message’s header is positioned out to the top-right corner, and it is a caption rather than a left border because that slot is already taken bymentionedandvisual-selected— a message can be any combination of the three.
_buildMsgHtml() has no view of the pin list, so it emits every message with a
blank pin button; the render passes (applyMessages on append,
prependMessages on a history page) hand their new nodes to
paintPinMarkers() afterwards. They pass only the nodes they touched — a full
repaint on every incoming message would walk the whole loaded window to compute a
result that cannot have changed for rows nothing rewrote.
refreshPins() does the first paint on each channel switch and clears the
outgoing channel’s pins immediately, so a stale count never lingers while the
request is in flight. The server caps a channel at MAX_PINS_PER_CHANNEL (50)
and truncates each preview to PIN_PREVIEW_CHARS (300), which bounds a payload
that is re-pushed to every open stream on the channel whenever it changes.
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 case-insensitive substring match and returns up to 50 results (restricted to channels the caller can read). The match is served by a trigram FTS5 index over the full message history, so it stays fast as history grows; queries shorter than three characters fall back to aLIKEscan.Client-side fuzzy search —
fuzzySearch(query, text)scores the returned candidates 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 —
notification.mp3is played through anAudioelement when a new message arrives and the user has not muted notifications. The mute state is persisted inlocalStorage['notifMuted']. See Sound Design for the full set.
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.
Sound Design
MiniMost ships six sounds, all in src/minimost/static/:
File |
Length |
Played when |
|---|---|---|
|
1.10 s |
A new message arrives ( |
|
4.20 s |
Someone is calling you — loops until answered or timed out. A warm bell arpeggio (A major) played twice per loop. |
|
4.00 s |
Your outgoing call is ringing — loops. A soft double ringback pulse on A4, then a long gap. |
|
1.20 s |
Someone answered. A rising perfect fifth, D5 → A5. |
|
1.20 s |
The call ended. The answer tone inverted: A5 → D5, falling. |
|
0.75 s |
Another participant left a call still in progress. A quiet descending blip. |
Provenance and licensing
The sounds are not sampled from anywhere. They are synthesised from
scratch by tools/gen_sounds.py, which is committed alongside them. They
are original work created for this project, carry no third-party licence, and
require no attribution — MiniMost can be redistributed and used commercially
without tracking terms for a handful of UI blips. Nothing was downloaded from
a sound library, so there is no external source to credit.
To change a sound, edit its recipe in tools/gen_sounds.py and re-run it;
do not hand-edit the MP3s, or the script and the shipped audio will disagree:
pip install numpy # not a runtime dependency — only needed to regenerate
python3 tools/gen_sounds.py
ffmpeg must be on PATH for the WAV → MP3 encode. The script overwrites
the six files in place and prints each one’s length and size.
How they are built
Each note is additive synthesis: a few sine partials, each with its own
amplitude and its own decay rate. Partials that fade faster than the
fundamental are what make the ear hear a struck object instead of a beep, and
their frequency ratios are what separate “wooden” from “metallic” — the
MARIMBA timbre puts overtones near 4x and 10x the fundamental, the BELL
timbre uses a nearly-harmonic stack with an inharmonic 2.76x partial. A short
synthetic reverb sits behind each sound.
Two details matter for correctness rather than taste:
Loop seams. Every MP3 carries roughly 26 ms of encoder padding that
<audio loop>does not splice out. The two looping ring sounds therefore start and end in silence, with the phrase faded to zero well before the seam, so the gap falls in silence and cannot be heard.Clicks. Notes are faded in over a few milliseconds and every file is faded out at its tail. Switching a waveform on or cutting it off mid-decay is a step change, and a step change is heard as a click.
Loudness is set only in the generator — each recipe normalises to the peak
it should actually play at, and the client never adjusts volume. Splitting
level between the audio files and the JavaScript is how a set of sounds drifts
out of balance. The relative levels are deliberate: the incoming ring is the
loudest (it has to carry across a room), a new message is noticeable, and the
in-call cues are quiet enough not to startle someone mid-sentence.
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.
Overlays (chat-modals.js)
Every overlay in the app — Account, Settings, Members, New DM, create/rename
private channel, and Help — is shown and hidden through one pair of functions
in chat-modals.js:
openModal(el, { display, label, focus });
closeModal(el);
openModal sets role="dialog" and aria-modal="true", applies the
overlay’s display value, records the element that had focus, and moves focus
inside. Where a dialog opens onto a field the caller passes it as focus;
otherwise focus lands on the dialog’s .modal-close-x button rather than
whatever comes first in the markup — in Account that would be Sign out.
closeModal hides the element and hands focus back to whatever opened it,
but only if focus is still inside the dialog: if the user clicked elsewhere
first, pulling focus back would be the surprise. When the opener is gone, or
nothing held focus to begin with, focus falls to the message list.
Open dialogs are tracked on a stack, and one document-level keydown
listener keeps Tab cycling inside the top-most one. Containment stands
down while an autocomplete list is open inside the dialog, since those fields
use Tab to accept the highlighted suggestion.
Help Dialog
The help overlay is a scrolling reference of thirteen sections, so it carries
a filter box and a jump list. filterHelp(query) hides every <li> that
does not match and then any section left empty; a section whose heading
matches keeps all its entries, so filtering for “calls” yields the whole Calls
section rather than one stray line. The jump list is built once from the
section headings themselves (_buildHelpToc), so adding a .help-sect to
the template is all it takes for a new pill to appear.
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.
Tiles, spotlight and grid:
_renderCall() is the single render pass for the call surface. It derives
the tiles that should exist right now — one per participant (including
yourself), one per invitee still ringing, and one per active screen share —
creates the missing ones with _createTile(), refreshes the rest with
_updateTile(), and drops the ones whose subject has gone.
Each tile is then placed either on the spotlight (#call-stage) or in
the grid (#call-participants-grid), which becomes a filmstrip beside
the spotlight when #call-panel carries has-stage. Tiles are cached in
callTiles and moved with appendChild rather than rebuilt, so a
<video> never loses its stream to a re-render.
_stageKey() picks the spotlight: an explicit pin wins, then the newest
screen share, then nothing. cycleCallLayout() steps callLayout
through auto → grid → stage, and togglePinTile() pins the tile
the user clicked, so a share starting later cannot yank the view away.
The grid itself is repeat(auto-fit, minmax(230px, 1fr)), so it reflows
from one large tile to a dense grid as people join without any JS.
toggleCallMinimized() docks the whole panel into a corner card
(.minimized) — same DOM, different box — so the channel stays usable
during a call.
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 incall_participants.sharing. Sharing is per participant, so any number of people can share at the same time.On the receiver side the new video track arrives via
ontrackand becomes that peer’s screen tile, which takes the spotlight unless the user has pinned something else._pollCallState()reconciles the server’sscreensharerslist against the tracks actually being received, as a backup for a share that stops without its track events arriving.Stopping (button, native “stop sharing”, or leaving) removes the track (renegotiating) and clears the
sharingflag; the receiver drops the tile 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 ( |
|
|
|
The rendered tiles keyed |
|
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 →
|
|
One entry per standalone share being watched (peer connection, tile and signalling cursor), and which of them is on the viewer’s stage. Empty when not viewing. |
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'].