web.strafe.chat is StrafeChat's web client, built with Solid.js. If you only have this
repo checked out, read this whole file before making changes — it's self-contained. The
full project (this repo, the Go backend, object storage, deployment) is normally
developed side by side; if you have that workspace too, its root AGENTS.md has the
complete picture.
StrafeChat is a Discord-alternative built around: privacy (minimum data collection, no
trackers, no silent telemetry), real end-to-end encryption (an actual ratchet, not a
static key — the E2EE layer under src/lib/e2ee is mid-overhaul toward a real
vodozemac-based Olm/Megolm implementation), federation (independent instances, no
single "official" server), self-hosting (this client must run against any instance's
API, configured via env, not hardcoded to one deployment), and client customization —
this repo owns that last one directly: themes, accessibility, notifications, keybinds,
appearance are first-class settings, not afterthoughts. Don't trade any of these away for
convenience — flag the conflict instead.
We reuse Discord's interaction model, never its names. Use these everywhere — component names, store fields, UI copy, i18n keys:
| Discord term | Our term |
|---|---|
| Server / Guild | Space |
| Channel | Room |
| Category | Section — not built yet; use this name when it is |
| DM | PM — sidebar label is "Private Messages" |
| AFK channel | UI copy says "inactive voice channel," not "AFK" |
| Server Widget | UI copy says "space widget" |
Never introduce "Server," "Guild," "Channel," "Category," or "DM" into user-facing copy or new i18n keys.
- State: one
createStoreper domain insrc/stores/*.ts(messages,spaces,rooms,customEmojis, ...). Realtime sync goes throughonStargateEvent(...)inside aninitXHandler()function that's called once at app init (seeApp.tsx/AppShell.tsx) — don't subscribe to gateway events from inside a component. - API layer: thin fetch wrappers in
src/api/*.tsbuilt onapi<T>()fromsrc/api/client.ts. A new endpoint gets a typed function here, not an inlinefetchin a component. - Design tokens: reuse what's in
src/theme/appChrome.ts(appTable*,appMenuPanel,appFloatToolbar,appDialogPanel,zLayer, ...) instead of hand-rolling equivalent Tailwind classes — this keeps every modal, table, and popover visually consistent. Add a new token there if an existing one doesn't fit, rather than writing one-off styles in a component. - i18n: all user-facing strings go through
t()(src/i18n), defined in all six locale files undersrc/i18n/locales/(en, es, fr, de, pt-BR, ar) — never partially. Countable strings use thekey_one/key_othersuffix convention (i18next-style); Arabic additionally needskey_zero/_one/_two/_few/_many/_other. A locale-parity check should show zero missing keys after you're done (only the expected plural-suffix differences are OK as "extra"). - Permissions:
src/lib/spacePermissions.tsmirrors the backend's permission bitmask bit-for-bit. If a permission bit is added or renamed on the backend, update this file in the same change so the two don't drift. - Space profiles (per-space nickname, avatar, banner, About Me, bio, pronouns): a member
row carries the global profile in the usual keys and the space one in
nick/space_*, and nothing merges them in a store. Any surface that names or pictures a person inside a space resolves throughresolveProfile/resolveSpaceUser(src/lib/spaceProfile.ts) instead of readingdisplay_nameoravatar; the shared points (getSenderDisplay, the popover subject builders,voiceParticipantName, mentions) already do. PMs, friends, invites, bans, the audit log's names and the admin pages stay global on purpose. Mentions still insert and tokenize the exact@username- a nickname is not unique and must never capture one. Nickname menus decide throughcanEditNicknameOf, the client copy of the server's rule. - Voice/video: everything goes through
src/stores/voice.ts- components never touch the LiveKitRoomdirectly. Voice states and calls come from the server (READY,VOICE_STATE_UPDATE,CALL_*, the join reply,refreshVoiceStates); the store only adds this device's connection, mic pipeline (src/lib/voice/micPipeline.ts), per-user volume and speaking flags. A snapshot of a room replaces its list and replays the events that landed meanwhile, never merges into it, and a left is matched by session, since a relay between instances can arrive after a rejoin. While in a call, LiveKit'sParticipantDisconnectedtakes that exact identity off the lists after a grace period (and fires for everyone on our own full reconnect, which must not count). Map-shaped store fields (speaking,media,byRoom) are written withproduce/reconcile, never a plain setter (which merges and keeps stale keys - that bug shipped once). One 48 kHzAudioContextper call carries both capture and playback (webAudioMixwith our context); it must be created/resumed synchronously in the click that joins, or Safari keeps it suspended and the call is silent until a tap. Noise suppression is RNNoise in an AudioWorklet (src/lib/voice/rnnoise.ts, bundled WASM, runs locally); Opus DTX stays off (comfort-noise switching audibly pulses). Tiles get measured pixel sizes fromfitTiles(a CSS aspect ratio ignored the stage height and overflowed the controls). UI lives insrc/components/voice/;docs/VOICE.mdexplains the whole flow. - Call media is E2EE. Per-sender keys (
src/lib/e2ee/callKeys.ts) travel Olm-encrypted over the to-device channel and feed LiveKit's key provider (src/lib/voice/e2ee.ts). When touching either: a peer's device list must be refreshed before encrypting or a member's new device never gets the key (themarkAllTrackedUsersAsDirtythrottle — this bit us once); the sender of an incoming key comes from the Olm decryption, never the payload; and there is no plaintext fallback, so never let a join proceed when encryption can't be set up. - Game activity ("Playing X"): the desktop shell recognises the game; the page only
forwards what the shell says to share, as gateway
op 9, fromsrc/stores/activity.ts- started once frominstallAppWideIndicators()insrc/desktop/boot.ts(main window only, never a popup or the phone app) and imported from nowhere else. Sharing is off by default and set per desktop account (Settings -> Activity, shown only whengamesAvailable()). On the display sidepresence.byUser[id].activitiesis written only bysetUserPresence(id, p, { activities: true }), and only from the payloads the server puts activities on (PRESENCE_UPDATE, our own presence in READY / GET /users/@me, friend relationship entries, space member lists and SPACE_MEMBER_ADD); every other presence write leaves it alone, or opening a room would wipe a live game. Someone leaving everything that showed us their game (unfriend or block,SPACE_MEMBER_REMOVE, our leaving the space, a READY that no longer lists them) is cleared withdropActivitiesOutsideAudience, since no event would ever clear it. Our own presence is never seeded from a room participant or a member row (that is the others' view, where invisible reads offline); the status menu goes throughsetOwnStatus, which applies a change to or from invisible only after the PATCH, so the report's clear never reaches the gateway ahead of the status. The store only reports while this page's own desktop account (pageDesktopAccountId()) is the active one, the shell'sgames_get_stateis for it, and no account switch is under way. Show a row's custom status throughcustomStatusOf(the store's whenever it has the user, so a cleared one cannot come back from the snapshot and hide the game). Read the activity throughactivityOf(userId), never from a snapshot, and render the name as plain text in a<bdi>(SentenceWithNameinsrc/components/ActivityStatus.tsx). Shared, non-JSX rules (name cleaning, the duration formatter) live insrc/lib/activity.ts. - Solid.js
<select>gotcha: the nativevalueprop can silently fail to apply if set before/independent of its<option>children rendering.src/components/ui/ Select.tsxalready works around this with aref+createEffectre-assert — don't regress it if you touch that component. - Reuse existing UI building blocks before adding new ones:
EmojiPicker(unicode + custom space emoji, already used by the composer and message reactions),ResponsiveDialog,Tooltip,IconButton,UserCell, the settings modal shells (SettingsShell/SettingsNav/SettingsPanel). Checksrc/components/for something close before writing a new component from scratch. - Developer platform & profile badges (client): the Developers settings section
(
src/components/settings/DevelopersSettingsPage.tsx) manages a person's OAuth2 applications and bots - name/description/redirect URIs, the bot card with its Public bot switch and Add to a space shortcut, and an OAuth2 URL generator (scopes, bot permissions grouped like the role editor, redirect URI) - and links to the docs. The standalone consent screen at/oauth2/authorize(src/pages/OAuthAuthorizePage.tsx) drives the authorization-code flow and, with thebotscope, the bot installer (space picker over the spaces the person manages, requested permissions with the ungrantable ones greyed, a success card when there is no redirect). Account → Authorized Apps (AuthorizedAppsSettingsPage.tsx) lists grants and revokes them. All throughsrc/api/developers.ts; a client secret or bot token is shown once in a copy-now banner and never persisted. A bot's user id is its application's client id, so the profile popover offers Add to Space on any bot whose application still exists (getPublicApplication). Roles withbot_idare managed bot roles: show the bot chip, never offer delete or assignment. Profile badges come from a user'spublic_flagsbitfield:src/lib/badges.tsmaps bits to icons andsrc/components/UserBadges.tsxrenders them (plus a BOT tag from thebotflag) beside the name in the profile popover and full modal. Threadpublic_flags/botthrough any new user-shaped payload the same way the existing subjects do. - Developer docs site (
docs/pages/*.md+docs/template.html,theme.css,docs.js, built byscripts/docs-plugin.mjs): served at/docs/in dev and emitted intodist/docs/byvite build, so every instance ships it. Plain Markdown with a front-matter block; standalone pages with their own strict CSP and no third-party assets. When you change an endpoint, scope, gateway event or permission bit, update the page that documents it in the same change.npm run docs:buildwrites the site to a directory for a quick look.
Before calling frontend work done: npm run typecheck and npm run lint must report no
errors. The lint config carries eslint-plugin-solid, which catches the bug class tsc
cannot: a component body runs once, so props.x ? a : b or an early return there freezes
at its mount-time value (a <Show> is the fix). Treat a solid/reactivity warning on a
props.* read or an early return as a real bug; the ones on imperative store reads inside
promise callbacks and event handlers are known false positives. Then actually start
the dev server and exercise the feature in a browser — click through the golden path and
at least one edge case, and check the console for new errors. Type-checking verifies code
correctness, not feature correctness; don't claim a UI change works without having seen it
render.