Skip to content

Latest commit

 

History

History
167 lines (156 loc) · 11.9 KB

AGENTS.md

File metadata and controls

167 lines (156 loc) · 11.9 KB

web.strafe.chat — AGENTS.md

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.

Mission — why this client exists

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.

Terminology

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.

Architecture conventions — follow these, don't reinvent them

  • State: one createStore per domain in src/stores/*.ts (messages, spaces, rooms, customEmojis, ...). Realtime sync goes through onStargateEvent(...) inside an initXHandler() function that's called once at app init (see App.tsx / AppShell.tsx) — don't subscribe to gateway events from inside a component.
  • API layer: thin fetch wrappers in src/api/*.ts built on api<T>() from src/api/client.ts. A new endpoint gets a typed function here, not an inline fetch in 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 under src/i18n/locales/ (en, es, fr, de, pt-BR, ar) — never partially. Countable strings use the key_one/key_other suffix convention (i18next-style); Arabic additionally needs key_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.ts mirrors 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 through resolveProfile / resolveSpaceUser (src/lib/spaceProfile.ts) instead of reading display_name or avatar; 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 through canEditNicknameOf, the client copy of the server's rule.
  • Voice/video: everything goes through src/stores/voice.ts - components never touch the LiveKit Room directly. 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's ParticipantDisconnected takes 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 with produce/reconcile, never a plain setter (which merges and keeps stale keys - that bug shipped once). One 48 kHz AudioContext per call carries both capture and playback (webAudioMix with 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 from fitTiles (a CSS aspect ratio ignored the stage height and overflowed the controls). UI lives in src/components/voice/; docs/VOICE.md explains 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 (the markAllTrackedUsersAsDirty throttle — 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, from src/stores/activity.ts - started once from installAppWideIndicators() in src/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 when gamesAvailable()). On the display side presence.byUser[id].activities is written only by setUserPresence(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 with dropActivitiesOutsideAudience, 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 through setOwnStatus, 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's games_get_state is for it, and no account switch is under way. Show a row's custom status through customStatusOf (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 through activityOf(userId), never from a snapshot, and render the name as plain text in a <bdi> (SentenceWithName in src/components/ActivityStatus.tsx). Shared, non-JSX rules (name cleaning, the duration formatter) live in src/lib/activity.ts.
  • Solid.js <select> gotcha: the native value prop can silently fail to apply if set before/independent of its <option> children rendering. src/components/ui/ Select.tsx already works around this with a ref + createEffect re-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). Check src/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 the bot scope, 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 through src/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 with bot_id are managed bot roles: show the bot chip, never offer delete or assignment. Profile badges come from a user's public_flags bitfield: src/lib/badges.ts maps bits to icons and src/components/UserBadges.tsx renders them (plus a BOT tag from the bot flag) beside the name in the profile popover and full modal. Thread public_flags/bot through 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 by scripts/docs-plugin.mjs): served at /docs/ in dev and emitted into dist/docs/ by vite 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:build writes the site to a directory for a quick look.

Verification expectations

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.