docs(ai): actualize architecture.md against current vojo/dev code after the Channels, Bots, threads and settings-route redesign

This commit is contained in:
heaven 2026-05-30 22:43:19 +03:00
parent a84c534179
commit 0e87787f95

View file

@ -1,189 +1,300 @@
# Architecture
> Last actualized 2026-05-30 against the code on `vojo/dev` (HEAD `a84c5341`). The
> Dawn redesign that this doc tracks has progressed well past the "P3c" baseline
> the older revision described: **Channels**, **Bots**, **threads**, a first-class
> **/settings/** route, the **mobile swipe pager**, and the **share-target** flow
> have all shipped. Where a plan doc (`docs/plans/*.md`) says something was
> "deferred", check the code first — much of it landed.
## Quick Start
```bash
npm start # dev server on :8080
npm start # dev server on :8080 (strictPort, host:true)
npm run build # production build → dist/
npm run lint # eslint + prettier
npm run lint # check:eslint (eslint --max-warnings 0 src) + check:prettier
npm run typecheck # tsc --noEmit
```
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
> **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls type-safe.
> **Note:** `.husky/pre-commit` runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files — NOT the full `npm run lint`). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green. Custom Matrix event-types live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — it augments `AccountDataEvents` with `in.vojo.spaces`, `io.element.recent_emoji`, `im.ponies.user_emotes`, `im.ponies.emote_rooms` and `StateEvents` with `im.ponies.room_emotes`, `in.vojo.room.power_level_tags`, `m.bridge`, `uk.half-shot.bridge` (all typed `unknown`; callsites use `.getContent<T>()`). Add new custom event-type strings there to keep `mx.getAccountData` / `mx.getStateEvent` type-safe.
## Source Layout
```
src/
├── index.tsx # Entry point
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
├── config.css.ts # fontWeight overrides
├── index.tsx # Entry point (calls pushSessionToSW, capacitor/electron link handlers)
├── colors.css.ts # darkTheme (Dawn) + lightTheme (Vojo) via createTheme(color, …) — both Vojo-owned
├── config.css.ts # onDarkFontWeight / onLightFontWeight overrides
├── sw.ts # hand-written service worker (auth media, push, language bridge)
├── sw-session.ts # pushSessionToSW(): re-posts setSession on controllerchange
├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
│ └── secretStorageKeys.js # Crypto callbacks
├── types/matrix/ # Matrix protocol types (room.ts, accountData.ts, common.ts)
├── types/matrix/ # Matrix protocol types + sdkAugmentation.d.ts
└── app/
├── i18n.ts # i18next config
├── i18n.ts # i18next config (single-file locales)
├── pages/
│ ├── App.tsx # Root component (providers, config loader)
│ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes
│ └── MobileFriendly.tsx # MobileFriendlyClientNav + MobileFriendlyPageNav (responsive split)
│ ├── App.tsx # Root component (ScreenSizeProvider, query client, config loader)
│ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes (~497 LOC)
│ ├── paths.ts # canonical path constants (DIRECT_PATH, CHANNELS_*, BOTS_*, …)
│ ├── pathUtils.ts # getXxxPath builders
│ ├── ThemeManager.tsx # UnAuthRouteThemeManager + AuthRouteThemeManager (body-class swap)
│ ├── HorseshoeContainer.tsx # app-shell wrapper + bottom call rail + share strip
│ ├── CallStatusRenderer.tsx / IncomingCallStripRenderer.tsx # global call surfaces
│ ├── MobileFriendly.tsx # MobileFriendlyPageNav (live) + MobileFriendlyClientNav (dead)
│ └── client/ # per-tab pages (direct, channels, bots, explore, space, home, create, settings, sidebar)
├── features/ # Feature modules
├── components/ # Shared components
├── hooks/ # ~117 custom hooks
├── state/ # Jotai atoms
├── plugins/ # Content plugins
├── utils/ # Utilities
└── styles/ # Vanilla-extract global styles
├── components/ # Shared components (~70 dirs)
├── hooks/ # ~132 custom hooks
├── state/ # Jotai atoms (~35 top-level files + room/, room-list/, hooks/, utils/)
├── plugins/ # Content plugins (call widget driver, emoji-data, color, react-prism)
├── utils/ # Utilities (room.ts, matrix.ts, time.ts, capacitor.ts, electron.ts)
└── styles/ # Vanilla-extract global styles (global.css.ts, horseshoe.ts)
electron/ # Electron desktop wrapper (see electron.md)
apps/widget-{telegram,discord,whatsapp}/ # Preact bot-widget apps (see overview.md)
```
## Pages & Routing (`src/app/pages/`)
Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/`, `/inbox/`) is wrapped in `PageRoot` with a `nav` prop (the tab's PageNav — e.g. `Direct`) and an `<Outlet/>` for the active room/sub-route.
Router in `Router.tsx::createRouter(clientConfig, screenSize)`. All authed routes hang
off one big `<Route>` whose `element` is the provider stack:
- **Auth** (`auth/login/`, `auth/register/`, `auth/reset-password/`) — NOTE: bistable layout fragility, see `bugs.md`. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. **Don't change `body`/`#root` background CSS-vars** — auth uses computed sizes for safe-area painting.
- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
- `home/`**Redirect-only shim after P3c.** `HomeRouteRoomProvider` redirects `/home/{roomId}/` to `/direct/{roomId}/` so cold-start push deep links and pre-P3c bookmarks resolve. No Home page or PageNav exists. The `/home/_create`, `/home/_join`, `/home/_search` routes redirect to `/direct/`.
- `direct/` — Universal room list (path: `/direct/`). After P3c contains every joined «orphan» non-space room (1:1 DMs, group DMs, group rooms, bridged chats, anything that used to live in `/home/`) plus every `m.direct`-tagged non-space room — implementation-wise `useOrphanRooms useDirects`, see [`useDirectRooms.ts`](../../src/app/pages/client/direct/useDirectRooms.ts) for the full union semantics. **Non-`m.direct` rooms that are space children stay only in the parent space tab** — they are not duplicated in `/direct/`. See `dm_1x1_redesign.md` §6.8.
- `space/` — Space view (path: `/:spaceIdOrAlias/`). Spaces (= future Channels) keep their own tab and child-room route; they render Stream-style timelines too.
- `explore/` — Public rooms (path: `/explore/`)
- `inbox/` — Notifications, invites (path: `/inbox/`)
- `create/` — New room/space (path: `/create/`)
- `sidebar/` — Tab components (`DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`). The legacy `HomeTab` was removed in P3c.
- `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : <Route index element={<WelcomePage />} />}`) — by design, not a bug.
- `SidebarNav.tsx` — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up `sidebar_cleanup` plan once the new Direct/Channels surfaces are self-sufficient.
- `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status
```
AuthRouteThemeManager → ClientRoot → ClientInitStorageAtom →
ClientRoomsNotificationPreferences → ClientBindAtoms → ClientNonUIFeatures →
CallEmbedProvider → HorseshoeContainer → ClientLayout(nav={null}) → <Outlet/>
```
### Universal Stream routing + DM classification (post-P3c)
**The global 66px `SidebarNav` rail is no longer mounted** — `ClientLayout` gets `nav={null}` (Router.tsx:249, with a Russian comment explaining its 5 buttons will be redistributed in a `sidebar_cleanup` pass). `SidebarNav.tsx` + `pages/client/sidebar/*` survive as **dead code**.
Each visible top-level tab is still wrapped in `PageRoot` with a `nav` prop (the tab's PageNav) and an `<Outlet/>` for the active room/sub-route.
### Top-level tabs / routes
| Route | Path const | What it is |
|---|---|---|
| `direct/` | `DIRECT_PATH = /direct/` | Universal room list — every joined "orphan" non-space room (1:1 DMs, group DMs, group rooms, bridged chats) every `m.direct`-tagged non-space room. `useOrphanRooms useDirects`. Non-`m.direct` space children stay only in the parent workspace. |
| `channels/` | `CHANNELS_PATH = /channels/` | **NEW.** Mattermost-style surface presenting **Spaces as "workspaces"**. Active workspace selection + room list + thread routing. |
| `bots/` | `BOTS_PATH = /bots/` | **NEW.** Bridge-bot catalog + per-bot widget/chat host. |
| `explore/` | `EXPLORE_PATH = /explore/` | Public rooms (featured + per-server). |
| `:spaceIdOrAlias/` | `SPACE_PATH = /:spaceIdOrAlias/` | **Legacy** space tab — still fully live (lobby + child rooms). Coexists with `/channels/`; both render the Channel timeline. |
| `create/` | `CREATE_PATH = /create` (no trailing slash) | New room/space. |
| `settings/` | `SETTINGS_PATH = /settings/` | **NEW first-class route.** Reuses the `/direct/` shell (DM list as left nav) with `SettingsScreen` in the right pane. `?page=` deep-links a sub-screen. On mobile it redirects to `/direct/` and opens `MobileSettingsHorseshoe` via `settingsSheetAtom`. Replaces the old `Modal500` settings dialog. |
| `home/` | `HOME_PATH = /home/` | **Redirect-only shim.** `HomeRouteRoomProvider` redirects `/home/{roomId}/``/direct/{roomId}/`, OR → `/channels/{space}/{roomId}/` when the room has orphan-space parents. `/home/create``/direct/create/`, `/home/{join,search}``/direct/`. Keeps cold-start push deep links + old bookmarks resolving. No Home page / HomeTab. |
| `u/:userIdOrLocalPart` | `USER_LINK_PATH` | `UserLinkRedirect` normalizes `vojo.chat/u/<user>``/direct/create?userId=<mxid>`. |
| `inbox/*` | — | **GONE.** Only a literal `'/inbox/*'``Navigate to /direct/` redirect remains (Router.tsx:485). No `INBOX_PATH`, no InboxTab. (The `Inbox` *i18n namespace* still exists for notification-card previews.) |
`pages/client/sidebar/` exports only `DirectTab, SpaceTabs, ExploreTab, SettingsTab, UnverifiedTab, SearchTab` (CreateTab, InboxTab, HomeTab all removed). `WelcomePage.tsx` is the desktop empty state; at index routes it is intentionally `mobile ? null` by design.
### Channels tab (`pages/client/channels/`)
- `Channels.tsx` exports **two** components: `ChannelsRootNav` (the `/channels/` index nav — `StreamHeader` segment switcher + `ChannelsLanding` empty-state, Plus = create space) and `Channels` (the workspace listing — `ChannelsWorkspaceHorseshoe` > `StreamHeader pinKey="channels"` > `ChannelsList`, `WorkspaceFooter`, Plus = create channel in active space; writes `activeChannelsSpaceAtom`).
- `ChannelsLanding.tsx` resolves the active space (URL > localStorage via `useActiveSpace` > first joined orphan) and `Navigate`-redirects to `/channels/:space/`.
- `ChannelPickPlaceholder.tsx` is the desktop center-pane stub when no room is selected.
- `WorkspaceSwitcherSheet.tsx` / `WorkspaceFooter.tsx` / `SpaceAvatar.tsx` build the workspace switcher.
- Rooms under `/channels/` are wrapped in `ChannelsModeProvider value={true}` and rendered through `SpaceRouteRoomProvider` (shared with the legacy space tree). The `/channels/` route is declared **before** the `/:spaceIdOrAlias/` catch-all so the prefix isn't swallowed.
### Bots tab (`pages/client/bots/`)
- `Bots.tsx` (listing) renders `StreamHeader pinKey="bots"` + `BotCard` rows from `useBotPresets()`. Catalog-only, no Matrix listeners.
- `/bots/:botId``BotExperienceHost.tsx` (lazy): resolves the preset, runs `useBotRoom(preset)` state machine (`none / self-invite / bot-invite / bot-kicked / unsafe-membership / ready`) → renders `BotNotConnected / BotInvitePending / BotKicked / BotUnsafeRoom / BotStatePage`, or on `ready` wraps `BotRoomProvider``BotExperienceRoute` which branches on `botShowChatAtomFamily(roomId)`: chat → `<Room renderRoomView={BotChatFallback}>`, else `<BotShell>` (the widget). See `features/bots/` for the host internals.
### Mobile responsive nav
- `MobileFriendlyPageNav(path)` (LIVE) — on Mobile renders the per-tab PageNav only when `useMatch(path, {end:true})` matches exactly; otherwise null (navigating into a room hides the list).
- `MobileFriendlyClientNav` is **dead code** (never invoked since `nav={null}`).
- `components/mobile-tabs-pager/` drives the mobile listing nav: `MobileTabsLayout` (a no-path layout route) renders `MobileTabsPager` only on **mobile + native + a listing-root URL** (`/direct/`, `/channels/`, `/channels/:space/`, `/bots/`); everywhere else it falls through to `<Outlet/>`. `MobileTabsPager` mounts Direct + (Channels|ChannelsRootNav) + Bots panes once and slides between them via CSS transform + swipe gesture, navigating with `replace`. It's intentionally **not** exported (only `MobileTabsLayout` is). Gesture is disabled while `settingsSheetAtom` / `channelsWorkspaceSheetAtom` are open. State: `activeChannelsSpace.ts`, `mobilePagerHeader.ts`, `settingsSheet.ts`, `channelsWorkspaceSheet.ts`.
### Lazy vs eager loading (load-bearing perf invariant)
**Eager** (top-level imports, NOT `React.lazy`): `Bots`, `Channels` + `ChannelsRootNav`, `Direct`/`DirectCreate`/`DirectRouteRoomProvider`, `Space`/`SpaceSearch`/`RouteSpaceProvider`/`SpaceRouteRoomProvider`, `HomeRouteRoomProvider`, `ChannelPickPlaceholder`, `WelcomePage`, `SettingsScreen`.
**Lazy** (`React.lazy` + `routeSuspense()`): `Room`, `Lobby`, `Explore`, `FeaturedRooms`, `PublicRooms`, `BotExperienceHost`, `Create`.
The Channels/Bots **listing** tabs must stay eager — lazy-splitting them reintroduced a web tab-switch flicker (a `grow="Yes"` Suspense fallback in the fixed-width nav slot reflowed the content column). See `bugs.md`. `BotExperienceHost` stays lazy and must be imported from the concrete file `./client/bots/BotExperienceHost`, not the barrel.
### Universal Stream routing + DM classification
```
/direct/:roomIdOrAlias
→ PageRoot (nav=Direct, outlet=…)
→ DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2)
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
→ RoomTimeline + RoomViewTyping + RoomInput
→ DirectRouteRoomProvider (ResolvedRoomProvider sets IsOneOnOneProvider reactively)
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for side panels)
→ RoomTimeline + RoomTimelineTyping + RoomInput
```
After P3c the timeline picks between two layouts via a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`). There is no DM-vs-non-DM render gate — the classification is purely participant-count + channels-route:
The timeline picks a layout via a single **member-count** check (Element-Web's tier-2 pattern). The authoritative decision lives in `RoomTimeline.tsx`:
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, the **Stream** timeline layout (rail + dot + bubble), and unconditionally hide membership/nick/avatar syslines.
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, the **Channel** timeline layout (avatar + in-bubble header + bubble — same silhouette as channels), and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
- Rooms under `/channels/` always get the Channel layout regardless of member count — `channelsMode` short-circuits the 1:1 check and additionally enables channels-only filtering (thread surfacing, RTC/edit hiding). See `RoomTimeline.tsx::channelStyleLayout`.
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
```ts
const isOneOnOne = useIsOneOnOne(); // member-count === 2 (from IsOneOnOneProvider)
const channelsMode = useChannelsMode(); // true for any room under /channels/
const channelStyleLayout = channelsMode || !isOneOnOne;
const messageLayout: 'stream' | 'channel' = channelStyleLayout ? 'channel' : 'stream';
```
`useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout.
- **Stream** layout (rail + dot + bubble, the DM "VS Code chat" look) = **1:1 non-channels rooms only**.
- **Channel** layout (avatar + in-bubble header + bubble, Discord-style) = **every group room (>2)** in any surface **AND every room under `/channels/`** regardless of member count.
- `channelsMode` additionally enables channels-only filtering (thread surfacing via `ThreadSummaryCard`, hiding thread-replies/edits/reactions/RTC from the centre column via `isChannelsModeHidden`).
- Bridged Telegram puppet rooms classify correctly because the gate is server-side authoritative; `isBridged = channelsMode && isBridgedRoom(room)` disables thread/RTC affordances.
Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group split — it reads the `IsOneOnOneProvider` context value set per route. Do NOT introduce a new `useIsDirect*` helper or the four-source `m.direct` gate that P3c removed (`isDirectStreamRoom`, `useIsDirectStream`, `IsDirectRoomProvider`, `useIsDirectRoom`). See `docs/plans/dm_1x1_redesign.md` §6.8 for the full rationale.
Route providers each wrap an inner `ResolvedRoomProvider` that runs the reactive `useIsOneOnOneRoom(room)` (subscribes `RoomStateEvent.Members`) and sets `<RoomProvider><IsOneOnOneProvider value=…>`:
- `DirectRouteRoomProvider` (`direct/RoomProvider.tsx`) — bounces invite rooms to `/direct/`, missing/space rooms to `JoinBeforeNavigate`.
- `SpaceRouteRoomProvider` (`space/RoomProvider.tsx`) — used by **both** legacy `/:space/:room` AND `/channels/:space/:room`; validates parent-child membership.
- `BotRoomProvider` — for bot rooms.
- `HomeRouteRoomProvider` only redirects (sets no IsOneOnOne context).
Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs group split. The old four-source `m.direct` gate (`isDirectStreamRoom`, `useIsDirectStream`, `IsDirectRoomProvider`, `useIsDirectRoom`, `IsStreamProvider`) is **fully removed** (zero grep hits) — do NOT reintroduce it. `useAutoDirectSync` still round-trips `m.direct` on join for **interop only** (so Element/FluffyChat agree); `mDirectAtom` is kept alive for `useDirectRooms` ordering and the `useDmCallVisible` ring gate, but is not load-bearing for layout.
## Features (`src/app/features/`)
| Dir | Purpose |
|-----|---------|
| `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (thin wrapper after P4) → **RoomViewHeaderDm.tsx** (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via `N members` line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the `…` menu), MembersDrawer (suppressed for 1:1 in `Room.tsx`), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — branches between **Stream** (1:1 rooms) and **Channel** (groups + channels) layouts on the `layout` prop driven by `RoomTimeline.tsx::channelStyleLayout`. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. |
| `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) |
| `room-settings/` | Room-specific settings page |
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
| `space-settings/` | Space-specific settings |
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — layout is no longer user-configurable (Stream/Channel pick automatically on member count), and the cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** |
| `lobby/` | Space/room lobby view |
| `search/` | Global search |
| `message-search/` | In-room message search |
| `create-chat/` | DM creation flow (recent commit 58ec12d split it into username + server fields) |
| `create-room/` | Room creation |
| `create-space/` | Space creation |
| `add-existing/` | Join existing rooms |
| `join-before-navigate/` | Pre-join navigation logic |
| `call/` | Element Call integration. **Vojo-DM voice call lifecycle lives here** (phases 0..5.35). Don't touch unless redesigning calls. |
| `call-status/` | Call state display + IncomingCallStrip (mounted as sibling in `Router.tsx:184`, **not** inside RoomViewHeader). |
| `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; search/pinned/invite/leave/settings/jump-to-time live in the `…` `RoomMenu`). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. |
| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? <ChannelLayout/> : <StreamLayout/>`), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `MessageEditor`, `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. |
| `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). |
| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Pairs with `pages/client/bots/`. |
| `share-target/` | **NEW.** Android/web system share-sheet hand-off. `ShareTargetStrip.tsx` is a top banner (mounted in `HorseshoeContainer`) shown while `pendingShareAtom` holds a payload; the next `RoomInput` mount consumes it (injects files + text, then nulls the atom). Native slot drained by `hooks/useShareTargetReceiver.ts`. |
| `call/` | **In-room call pane**`CallView` (prescreen/join screen + member list + livekit checks), `CallControls`/`Controls`/`PrescreenControls`/`CallMemberCard`. Mounted in `Room.tsx` via `<CallView/>`. Consumes `plugins/call` CallEmbed + `state/callEmbed`. Don't unmount/remount the widget root carelessly — Android FGS is keyed on `joined`. |
| `call-status/` | **Global bottom call rail**`IncomingCallStrip` (incoming-ring row) + `CallStatus` (active-call pill) + `CallControl`. Mounted via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` inside `HorseshoeContainer` (NOT directly in Router). Call **lifecycle** hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`, `usePendingCallActionConsumer`) run in Router's `IncomingCallsFeature()`. |
| `settings/` | User settings as 7 pages (`GeneralPage, AccountPage, NotificationPage, DevicesPage, EmojisStickersPage, DeveloperToolsPage, AboutPage`; `SETTINGS_PAGE_PARAM` deep-links). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` / `hour24Clock` / `dateFormatString` were removed — layout is no longer user-configurable and time/date derive from the runtime locale (`utils/time.ts`). `hideMembershipEvents` / `hideNickAvatarEvents` survive (gate group-room syslines). **Logout lives here only** (`LogoutDialog`). `MobileSettingsHorseshoe` + `SettingsScreen` are the mobile sheet / route entry. |
| `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). |
| `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. |
| `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. |
| `search/` | Global room/space switcher modal (Cmd+K style) — `Search.tsx` (mounted via `SearchModalRenderer`) + `useRoomSearch.ts` (extracted `useAsyncSearch` over directs/rooms/spaces/orphan-spaces). |
| `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). |
| `create-chat/` | DM creation — username + server fields (`FALLBACK_SERVER='vojo.chat'`), dedup via `getDMRoomFor`. |
| `create-room/` / `create-space/` | Room/space creation (`CreateRoom`/`CreateSpace` + their modal wrappers, mounted via `*ModalRenderer`). |
| `add-existing/` | Add existing rooms to a space. |
| `join-before-navigate/` | Pre-join room card (`JoinBeforeNavigate.tsx`, ~82 LOC). |
### Virtualization in features/room/
`RoomTimeline.tsx` does **not** use `@tanstack/react-virtual`. It uses **`useVirtualPaginator`** + `IntersectionObserver` for pagination + scroll-anchoring. There is no `estimateSize` to retune; row heights are measured live. (The tanstack `useVirtualizer` is used elsewhere — e.g. `Direct.tsx:199` for the DM-list column with `estimateSize: () => 38`.)
`RoomTimeline.tsx` does **not** use `@tanstack/react-virtual`. It uses **`useVirtualPaginator`** + `IntersectionObserver` for pagination + scroll-anchoring; row heights are measured live (no `estimateSize`). The tanstack `useVirtualizer` is used elsewhere for **list panels** (e.g. the DM-list column in `Direct.tsx`, via `components/virtualizer/VirtualTile.tsx`).
## Key Components (`src/app/components/`)
- `message/` — Message rendering. Only `Stream.tsx` and `Modern.tsx` layouts ship after P3c (`Compact`/`Bubble` deleted along with `MessageLayout` enum). Every timeline row uses `<StreamLayout/>`. `Modern.tsx` survives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines. `EventContent.tsx` is a single-branch sysline renderer (the legacy `IsStreamProvider` context was deleted; rail metadata flows in via `railStart` / `railEnd` props directly).
- `message/MessageStatus.tsx` + `hooks/useMessageStatus.ts`**Kept but no longer rendered in the timeline.** P3c retired the WhatsApp-style checkmarks because the Stream-rail dot now encodes the same delivery / read state via colour and opacity. `useMessageStatus` is still consumed inside `useDotColor.ts`, so the file is load-bearing.
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
- `emoji-board/` — Emoji picker
- `image-pack-view/` — Custom emoji pack management
- `image-viewer/`, `Pdf-viewer/` — Media viewers
- `sidebar/``Sidebar.tsx` (66px wrapper), `SidebarItem.tsx`, `SidebarStack.tsx`, `SidebarContent.tsx`, `Sidebar.css.ts`
- `user-profile/` — User info, power chips, moderation
- `member-tile/` — Member list items
- `power/` — Power level UI
- `upload-card/` — Upload progress cards
- `url-preview/` — Link previews
- `page/` — Page layout wrapper (`Page`, `PageRoot`, `PageNav`, `PageHeader`, `PageContent`, `PageHero`)
- `setting-tile/` — Settings list item pattern
- `sequence-card/`, `cutout-card/` — Card layouts
- `uia-stages/` — User-interactive auth stages (email, captcha, token)
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
### Message rendering — `message/`
- `message/layout/`**three shipping layouts** + base: `Stream.tsx` (1:1/Bots rail+dot+bubble, `STREAM_MESSAGE_SPACING='400'`), `Channel.tsx` (groups + channels: avatar + in-bubble header, `CHANNEL_MESSAGE_SPACING='300'`, `headerInBubble` compact mode for the thread drawer), `Modern.tsx` (card-preview only — pin-menu, message-search, `DefaultPlaceholder`; inbox is gone), `Base.tsx` (MessageBase/AvatarBase/Username primitives). `Compact.tsx`/`Bubble.tsx` and the `MessageLayout` enum are deleted. Also `layout.css.ts`, `Channel.css.ts`, `streamDebug.ts`.
- `message/content/``EventContent.tsx` is now a **two-branch** sysline renderer (`layout?: 'stream'|'channel'`; `'channel'``<ChannelEventContent>`, else the Stream rail/dot grid). The legacy `IsStreamProvider` context is gone; rail metadata flows via `railStart`/`railEnd` props. Plus `Image/Video/Audio/File/Thumbnail/FallbackContent`.
- `message/attachment/``Attachment.tsx` + the Stream-media bubble shells (`StreamMediaShell` aspect-clamped 320px, `StreamMediaImage`, `StreamMediaVideo`).
- `message/placeholder/``DefaultPlaceholder`, `LinePlaceholder`.
- Top-level: `MessageStatus.tsx` (kept, **not rendered in timeline** — consumed only by `hooks/useMessageStatus.ts``hooks/useDotColor.ts`, which encodes delivery/read state on the Stream rail dot: red=failed, green=read, gold=mention, gray=neutral), `Reaction`, `Reply`, `Time`, `RenderBody`, `FileHeader`.
### Editor — `editor/`
Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elements`, `Toolbar`, `input/output/keyboard/utils`. `editor/autocomplete/`: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `AutocompleteMenu`. **`CommandAutocomplete` is NOT here** — it lives in `features/room/`.
### Media / viewers
- `media/` (**NEW**) — primitive `<Image>`/`<Video>` wrappers + `MediaControls` layout shell.
- `image-viewer/`, `Pdf-viewer/`, `text-viewer/` (**NEW**, lazy Prism), `image-editor/` (**NEW**, currently a stub — `handleApply` is a no-op), `ImageOverlay.tsx` (Modal viewer wrapper used by url-preview).
- `emoji-board/` — emoji picker. `image-pack-view/` — custom emoji/sticker pack management.
### Avatars / user / member (mostly NEW dirs)
- `user-avatar/`, `room-avatar/` (also exports `RoomIcon`), `stacked-avatar/` — avatar primitives (force circle via globalStyle).
- `user-profile/` — Dawn profile card: `UserRoomProfile` (root), `UserHero`, `UserInfoRows`, `UserChips` (UserActionsMenu + MutualRoomsChip), `UserModeration`, `PowerChip`, `CreatorChip`.
- `member-tile/`, `members-list/` (**NEW** — `MembersList` Dawn members-sheet body + `RoomMembersHero`), `power/` (PowerColorBadge/Icon/Selector), `presence/` (**NEW** — PresenceBadge online/away/offline dot), `event-readers/` (**NEW** — read-receipt popout).
### Navigation / list (mostly NEW dirs)
- `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`.
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Chip`/`Segment` + `useCurtain*` gestures + `forms/InlineNewChatForm`/`InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`.
- `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing).
- `sidebar/``Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`.
### Cards / layout primitives
`page/Page.tsx` (exports `Page, PageRoot, PageNav, PageNavHeader, PageNavContent, PageHeader, PageContent, PageHero…, HorseshoeEnabledContext`), `sequence-card/`, `cutout-card/`, `info-card/` (**NEW**), `room-card/` (**NEW**, ~328-LOC joinable-room card with join flow), `room-topic-viewer/` (**NEW**, topic Modal), `setting-tile/`.
### Badges / indicators / upload / preview
`unread-badge/`, `server-badge/` (**NEW**), `typing-indicator/` (**NEW**), `time-date/` (**NEW** — DatePicker/TimePicker/PickerColumn), `upload-card/`, `upload-board/` (**NEW**), `url-preview/`.
### Prompts / dialogs
`invite-user-prompt/`, `leave-room-prompt/`, `leave-space-prompt/` (**NEW**), `full-screen-intent-prompt/` (**NEW** — Android FSI permission, 7-day cooldown), `push-permission-prompt/` (**NEW** — push permission, 7-day cooldown), `uia-stages/` (Dummy/Email/Password/ReCaptcha/RegistrationToken/SSO/Terms), `LogoutDialog`. (`join-address-prompt/` from the old doc **does not exist**.)
### Boot/runtime Loaders & Providers (top-level render-prop components)
- `ClientConfigLoader` (fetch `/config.json`, 10s timeout), `MediaConfigLoader`, `CapabilitiesLoader`, `ServerConfigsLoader` (allSettled capabilities+mediaConfig+authMetadata), `SpecVersionsLoader` (CS-API probe, soft-degrade), `AuthFlowsLoader`, `SupportedUIAFlowsLoader`.
- `RoomSummaryLoader` (+ `LocalRoomSummaryLoader`, `HierarchyRoomSummaryLoader`, react-query), `RoomUnreadProvider`/`RoomsUnreadProvider` (`roomToUnreadAtom`), `SpaceChildDirectsProvider`/`SpaceChildRoomsProvider`, `UseStateProvider`.
- `CallEmbedProvider.tsx` (**NEW**, Vojo call surface) — mounts the fixed-position Element Call widget container, provides `CallEmbedContext`/`CallEmbedRefContext`, runs `CallUtils` + `useAndroidCallForegroundSync`. **Load-bearing — Android FGS is keyed on `callEmbedAtom`.**
- Crypto/verification cluster: `SecretStorage`, `BackupRestore`, `DeviceVerification`/`Setup`/`Status`, `ManualVerification`.
- Misc: `AccountDataEditor`, `JoinRulesSwitcher`, `RoomNotificationSwitcher`, `MemberSortMenu`, `MembershipFilterMenu`, `HexColorPickerPopOut`, `BetaNoticeBadge`, `Modal500` (mobile horseshoe modal shell), `RenderMessageContent` (msgtype dispatcher + `StreamMediaContext`), `password-input/PasswordInput`, `ConfirmPasswordMatch`, `ActionUIA`/`UIAFlowOverlay`, `BackRouteHandler` (web back-button → back-stack collapse via `replace`).
## Vojo-specific code paths (preserve when redesigning)
These are vojo additions on top of stock Cinny — they crossed many recent stabilization commits and are now load-bearing.
| Path | Commits | Notes |
|---|---|---|
| `features/room/RoomViewHeaderDm.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point. Gated on **`useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room)`** after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which still gate ring delivery and caller-side auto-hangup on `m.direct`, (3) explicit MSC2346 `m.bridge` exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan. |
| `features/call/*` (CallEmbed, CallView) | 91afffc, e8188d7, fab533e | Element Call widget; **don't unmount/remount the widget root on header redesign** — Android FGS is keyed on `joined` state |
| `features/call-status/IncomingCallStrip.tsx` | dabe5ca, 91afffc | Mounted as sibling in `Router.tsx:184` (not inside header). Decline / accept / mid-ring backgrounding handled here |
| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle |
| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | 0c4cfb9 | WhatsApp checkmarks |
| `hooks/usePushNotifications.ts` | dabe5ca, 84eeac9 | Push tap routing: warm web/native paths resolve DMs to Direct via `navigateRoom` / `getDirectRoomPath` (lines 309 / 387); SW cold-start opens `/home/{roomId}/` and after P3c `HomeRouteRoomProvider` redirects every non-space room to `/direct/` (the `m.direct` predicate was lifted in P3c §6.7). FSI hand-off |
| `hooks/useRoomNavigate.ts` | dce6be9 | Back-stack collapse via `replace`. **Path-based** (`pathnameRef.current === target ? replace : push`, line 49-55) — not tab-ID-keyed, so removing `*Tab.tsx` rendering does not break back-stack |
| `hooks/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | dce6be9 | Hardware back integration |
| `hooks/useAutoDirectSync.ts` + `utils/matrix.ts` | 84eeac9 | m.direct sync on join (DM rooms shown correctly for invited users) |
| `pages/auth/*` | c466848, e6623b2, cf5ee9a, 9b41cbb | Bistable layout — don't change body/root background CSS-vars; see `bugs.md` |
| `MainActivity.java::EdgeToEdge.enable(this)` + `src/index.css` lines 45-65 + `styles.xml` `windowLayoutInDisplayCutoutMode=shortEdges` | 1edaf60 | **Android edge-to-edge compensation** (white-bar fix). WebView draws under status/nav bars; compensation triad: `body { background-color: var(--oq6d070) }` (currently folds `color.Background.Container`**but `--oq6d070` is folds@2.6.2 internal implementation detail, not public API**; can break on folds upgrade), `#root { padding: env(safe-area-inset-*) }`, and the manifest cutout mode. Break any one and white bars return or status text overlaps content. **DM redesign P0 replaces with Vojo-owned `--vojo-safe-area-bg` variable** + matching `WindowCompat.setAppearanceLight{Status,Navigation}Bars(false)` in `MainActivity.onCreate` to keep system-bar icons visible across uiMode. See `dm_1x1_redesign.md` §6.6 / R13 (safe-area var) and R19 (Android icon tint) for full risk matrix. |
| `scripts/gen-push-strings.mjs` + `android/app/build.gradle` | 19d3dc0, 7af04a4 | Gradle task generates `push_strings.xml` from i18n `Push.json`. **Don't put non-push i18n keys in the `Push` namespace** or they leak into lockscreen XML. EN+RU must be added together (web SW falls back to EN if RU missing) |
| `android/app/src/main/java/**` | calls phases 5.35 | FGS, FCM, ring registry, declined-IDs tracker |
| Path | Notes |
|---|---|
| `hooks/useDmCallVisible.ts` | **Single source of truth for the DM call button.** FOUR gates: `useIsOneOnOne() && mDirectAtom.has(roomId) && !useIsBridgedRoom(room) && !isCatalogBotControlRoom(...)`. Consumed by both `RoomViewHeaderDm::DmCallButton` (+ `!callView`) and `UserRoomProfile`'s Call action so they can't drift. (The bot-control-room gate excludes bridge bots; the bridge gate excludes mautrix puppet rooms via MSC2346 `m.bridge`.) Lifecycle hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) still gate ring delivery on `m.direct`. |
| `features/call/*` + `components/CallEmbedProvider.tsx` | Element Call widget; **don't unmount/remount the widget root on header redesign** — Android FGS is keyed on `joined`. |
| `features/call-status/*` via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` | The incoming-ring strip + active-call pill render inside `HorseshoeContainer`'s bottom rail (mounted at `Router.tsx:242` inside `CallEmbedProvider`), **not** directly in Router. |
| `state/callEmbed.ts`, `state/incomingCalls.ts`, `state/pendingCallAction.ts` | Call embed lifecycle, ring queue (`incomingCallsAtom` + derived `isRingingAtom`), native push-action bridge (answer/decline). |
| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | Kept but not rendered — feeds `useDotColor` for the Stream rail dot. |
| `hooks/usePushNotifications.ts` | Push tap routing: warm web/native paths resolve DMs to Direct via `navigateRoom`/`getDirectRoomPath`; SW cold-start opens `/home/{roomId}/` and `HomeRouteRoomProvider` redirects to `/direct/` or `/channels/`. Invite-state rooms bounce to bare `/direct/`. FSI hand-off. |
| `hooks/useRoomNavigate.ts` | Back-stack collapse via `replace`**path-based** (`pathnameRef.current === target ? replace : push`), so removing tab rendering doesn't break the back-stack. |
| `hooks/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | Hardware back integration. |
| `hooks/useAutoDirectSync.ts` + `utils/matrix.ts` | `m.direct` sync on join (interop), skips bridged + >2-member rooms. |
| `src/sw.ts` + `src/sw-session.ts` | Authenticated Matrix media (Bearer-token fetch on `/_matrix/client/v1/media/*`), push notifications with EN/RU fallback, IndexedDB language bridge. `pushSessionToSW()` re-posts `setSession` on `controllerchange` + `sw.ready` to survive the first-load null-controller race (logout passes `undefined` to clear). |
| `pages/auth/*` | Bistable layout — don't change body/root background CSS-vars; see `bugs.md`. |
| Android edge-to-edge / safe-area | `body { background-color: var(--vojo-safe-area-bg, #0d0e11) }` (`--vojo-safe-area-bg` bound to `color.Background.Container` in `styles/global.css.ts`); `#root { padding: env(safe-area-inset-{left,right}) }` (top/bottom zero); `--vojo-safe-top: env(safe-area-inset-top)` padded down by ~15 top-anchored components (reset to 0 inside the mobile horseshoes). `MainActivity.java` + `windowLayoutInDisplayCutoutMode=shortEdges`. See `android.md`. |
| `scripts/gen-push-strings.mjs` + `android/app/build.gradle` | Gradle task generates `push_strings.xml` from i18n `Push` namespace. Don't put non-push keys in `Push`; add EN+RU together. |
## State Management
**Jotai** atoms in `src/app/state/`:
**Jotai** atoms in `src/app/state/` (~35 top-level files + `room/`, `room-list/`, `hooks/`, `utils/`). Helpers: `utils/atomWithLocalStorage.ts` (hydrate + persist + cross-tab `storage` sync), `list.ts` (`createListAtom` PUT/REPLACE/DELETE factory). Access via hooks in `state/hooks/`; `useBindAtoms.ts` wires the matrix-listener atoms once at boot.
- `settings.ts` — User preferences (`themeId`, `useSystemTheme`, `monochromeMode`, `hideMembershipEvents`, `hideNickAvatarEvents`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum + `messageSpacing` + `legacyUsernameColor` fields were dropped in P3c; the new `dawn-p3c-cleanup` migration in `getSettings()` strips the orphan keys from existing users' persisted JSON on first load. The user-facing `hour24Clock` / `dateFormatString` fields were removed too — both now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts` (24-hour locales → `HH:mm` + `DD/MM/YYYY`; AM/PM locales → `hh:mm A` + `MM/DD/YYYY`). The `system-time-format-cleanup` migration synchronously deletes those keys on first load. **Known platform limitation**: Android's manual «Use 24-hour format» toggle in Date & Time settings is invisible to JS — `Intl` reads only CLDR locale conventions. Russian-locale users with AM/PM toggle get 24-hour format on both web and Capacitor; only a native bridge to `android.text.format.DateFormat.is24HourFormat(context)` would respect that toggle.
- `sessions.ts` — Active session
- `upload.ts` — Upload progress (in-memory)
- `room/``roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
- `room-list/``roomList`, `mDirectAtom`, `inviteList`, sorting/filtering
- `callEmbed.ts``callEmbedAtom`, `callChatAtom` (call lifecycle)
- `closedNavCategories.ts``closedNavCategoriesAtom` (collapsed/expanded folders)
**Persisted to localStorage:**
- `settings.ts``settingsAtom` (key `settings`; custom `getSettings`/`setSettings`, see below).
- `sidebarWidth.ts` / `threadDrawerWidth.ts` / `mediaSidePanelWidth.ts` — desktop column widths with clamp helpers (sidebar MIN 384/DEF 416; thread MIN 320/DEF 420; media MIN 360/DEF 520/HARD_MAX 880, max accounts for rail + pageNav + void gaps + chat reserve).
- `activeChannelsSpace.ts``activeChannelsSpaceAtom` (key `vojo.activeSpaceId`, stored RAW not JSON for back-compat) — Channels active workspace.
- `spaceRooms.ts` (Set of space-summary child rooms), `navToActivePath.ts`, `closedNavCategories.ts`, `closedLobbyCategories.ts`, `openedSidebarFolder.ts`, `callPreferences.ts` — most are `make…Atom(userId)` per-user factories.
Some atoms persist to localStorage (e.g. `settings.ts`, `navToActivePath.ts`), others are in-memory only (e.g. `upload.ts`, `roomInputDrafts.ts`). Access via hooks in `state/hooks/`.
**In-memory only:**
- `incomingCalls.ts` (`incomingCallsAtom` + `isRingingAtom`), `pendingCallAction.ts`, `pendingShare.ts`.
- Right-pane sheet atoms (**mutually exclusive** — opening one clears the others via `state/hooks/`): `userRoomProfileAtom`, `mediaViewerAtom`, `roomMembersSheetAtom`, plus `settingsSheetAtom` (mobile-only Settings) and `channelsWorkspaceSheetAtom`.
- `mobilePagerHeader.ts` (`mobilePagerCurtainAtom`, `curtainPinnedByTabAtom`, `mobileHorseshoeActiveAtom`), `viewedRoom.ts` (`viewedRoomIdAtom` — cross-route "room on screen" for URL-less routes like `/bots/:botId`).
- `mDirectList.ts` (`mDirectAtom` + `useBindMDirectAtom`), `callEmbed.ts` (`callEmbedAtom`, `callChatAtom`), `typingMembers.ts` (gated on `hideActivity`), `upload.ts`, `lastCompositionEnd.ts`, `searchModal.ts`, `backupRestore.ts`, `createRoomModal.ts`/`createSpaceModal.ts`, `roomSettings.ts`/`spaceSettings.ts`.
- `state/room/``roomInputDrafts.ts` (draft families keyed by **tuple `[roomId, threadKey]`** so the thread drawer keeps independent drafts), `roomToParents.ts`, `roomToUnread.ts`.
- `state/room-list/``roomList.ts` (`allRoomsAtom`), `inviteList.ts` (`allInvitesAtom`), `utils.ts`.
- `sessions.ts`**no live atom** (all atom code is commented out); only `getFallbackSession`/`setFallbackSession`/`removeFallbackSession` + the legacy cinny→vojo localStorage key migration remain active.
### settings.ts fields & migrations
Current `Settings` fields: `themeId? ('light-theme'|'dark-theme')`, `useSystemTheme`, `monochromeMode?`, `isMarkdown`, `editorToolbar`, `twitterEmoji`, `pageZoom`, `hideActivity`, `isPeopleDrawer`, `memberSortFilterIndex`, `enterForNewline`, `hideMembershipEvents`, `hideNickAvatarEvents` (default true), `mediaAutoLoad`, `urlPreview`, `encUrlPreview`, `showHiddenEvents`, `isNotificationSounds`, `inviteSpamFilter`, `developerTools`, `migrationsApplied?`.
`getSettings()` runs three one-shot migrations: `dawn-redesign-v1` (pins **existing** users — stored JSON present — to dark; brand-new users keep `useSystemTheme:true`), `dawn-p3c-cleanup` (drops `messageLayout`/`messageSpacing`/`legacyUsernameColor`), `system-time-format-cleanup` (drops `hour24Clock`/`dateFormatString` — time/date now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts`). **Known platform limitation**: Android's manual "24-hour" toggle is invisible to `Intl`; only a native bridge to `DateFormat.is24HourFormat` would respect it.
## Theming
Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae).
Stock Cinny had multiple themes; Vojo simplified to **System / Light / Dark**.
- `src/colors.css.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table.
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves).
- Folds tokens (`color.*`, `config.space`, `config.radii`, `config.borderWidth`) are read-only inside folds compiled CSS. Re-skinning colours via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.
- The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The `dawn-redesign-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so they follow the OS preference out of the box.
- `src/colors.css.ts` defines `darkTheme` (Dawn palette) and `lightTheme` (Vojo light) via `createTheme(color, …)` — both Vojo-owned (folds default `lightTheme` not imported). `config.css.ts` adds `onDarkFontWeight`/`onLightFontWeight`.
- `hooks/useTheme.ts`: `DarkTheme.classNames = ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark']`, `LightTheme` mirror. `useActiveTheme()` picks on `useSystemTheme` + `themeId`.
- `pages/ThemeManager.tsx` exports **two** components (no single `ThemeManager`): `UnAuthRouteThemeManager` (auth routes — follows OS `prefers-color-scheme` only) and `AuthRouteThemeManager` (authed routes — reads `useActiveTheme()`, swaps body class, applies `monochromeMode` as `document.body.style.filter = 'grayscale(1)'`, provides `ThemeContext`). Both do `body.className=''` then re-add the theme classes. Runtime switching is live (body-class swap, no reload), but adding new tokens still needs a rebuild.
- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `#5b6aff` (indigo).
- The Appearance picker (Settings → General) offers System / Light / Dark.
- **Stream/bubble CSS vars** live in `src/index.css` (`:root` light defaults, `.dark-theme` overrides): `--vojo-horseshoe-void`, `--vojo-peer-bubble-bg`, `--vojo-timeline-rail`, `--vojo-stream-name-own`, `--vojo-stream-name-peer`, `--vojo-dot-neutral`. The incoming-call orbit needs `@property --vojo-orbit-angle` + `@keyframes vojo-orbit-sweep` declared in raw `index.css` (vanilla-extract lacks `@property`).
- Horseshoe seam: `--vojo-horseshoe-void` is `#d6d6e3` (light) / **`#000000`** (dark). `styles/horseshoe.ts` exports `VOJO_HORSESHOE_VOID_COLOR`, `VOJO_HORSESHOE_GAP_PX = 12`, `VOJO_HORSESHOE_RADIUS_PX = 32`; consumed by `HorseshoeContainer`.
### Known follow-ups for light theme
The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `src/index.css`, dual `<meta theme-color>` in `index.html`). Native and PWA chrome are NOT yet bound to the active theme — track these as separate tasks:
The **web** theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism highlighting, `--vojo-safe-area-bg`, cold-start `prefers-color-scheme` fallback in `index.css`, dual `<meta theme-color>` `#0d0e11`/`#f2f2f7`). Native and PWA chrome are NOT yet bound to the active theme (all verified still hardcoded dark):
- **Android system bars**`MainActivity.java::onCreate` hardcodes `controller.setAppearanceLight{Status,Navigation}Bars(false)`. On light theme the icons are white over a light bar → invisible. Fix is a small JS↔Java bridge (custom Capacitor plugin, or `@capacitor/status-bar` for status-bar tint + custom plugin for nav-bar) driven from `ThemeManager`'s `useEffect`.
- **Android native splash**`android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11` and `styles.xml::windowBackground` are dark. Light users see a dark splash → fade to white. Add `values-night/` variants or read the stored `themeId` from a SharedPreferences shim before paint.
- **Capacitor WebView paint color**`capacitor.config.ts::backgroundColor = '#0d0e11'` (mirrored in the built `capacitor.config.json`). Set at WebView init, cannot be re-themed at runtime via JS — needs the splash-fix above to land first.
- **PWA manifest**`public/manifest.json` `theme_color`/`background_color` are pinned to dark (`#0d0e11`). Manifest format does not support media queries, so the choice is one default; we keep dark because the migration also pins existing users to dark.
- **AuthLayout**`src/app/pages/auth/styles.css.ts` hardcodes dark backgrounds (`#0d0e11` etc.) for the bistable auth scaffold (see `bugs.md` for why the auth layout cannot be naively re-skinned). Light-theme users see a dark login/register/reset-password screen. Tied to the auth bistable-layout refactor.
- **Bot widgets**`BotShell.css.ts`, `BotWidgetMount.css.ts`, `BotCard.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e` accent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing through `apps/widget-*` or a CSS-var bridge from the parent.
The horseshoe void seam reshades via the `--vojo-horseshoe-void` CSS variable: dark `#090909` (deep void against `#0d0e11` panel) and light `#d6d6e3` (soft lavender-grey against `#f2f2f7` panel). See `src/app/styles/horseshoe.ts` + `src/index.css`.
- **Android system bars**`MainActivity.java::onCreate` hardcodes `setAppearanceLight{Status,Navigation}Bars(false)`.
- **Android native splash**`res/values/colors.xml::splash_bg = #0d0e11` + `styles.xml::windowBackground`; no `values-night/`.
- **Capacitor WebView paint**`capacitor.config.ts::android.backgroundColor = '#0d0e11'`.
- **PWA manifest**`public/manifest.json` `theme_color`/`background_color` = `#0d0e11` (no media-query support).
- **AuthLayout**`pages/auth/styles.css.ts` hardcodes `#0d0e11` (tied to the auth bistable-layout refactor; the file also carries its own `max-width`/`max-height` layout media queries).
- **Bot widgets**`BotShell.css.ts` / `BotWidgetMount.css.ts` / `BotCard.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e`; each widget is a separate Preact app without Vojo's folds tokens.
## Composer card geometry
Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap `RoomInput` with the `ChatComposer` class). The composer is a floating rounded card with **32px corner radius** (`VOJO_HORSESHOE_RADIUS_PX`); all paddings are tuned so the visible glyphs (text, IconButton icons) stay outside the curve clip. Source of truth: [`src/app/features/room/RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts), [`src/app/features/room/RoomInput.tsx`](../../src/app/features/room/RoomInput.tsx) (action-row padding).
Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap `RoomInput` with the `ChatComposer` class). Floating rounded card with **32px corner radius** (`VOJO_HORSESHOE_RADIUS_PX`); paddings tuned so visible glyphs stay outside the curve clip. Source of truth: [`RoomView.css.ts`](../../src/app/features/room/RoomView.css.ts), [`RoomInput.tsx`](../../src/app/features/room/RoomInput.tsx), [`Editor.css.ts`](../../src/app/components/editor/Editor.css.ts). **All values below verified unchanged 2026-05-30.**
| Element | Value | Where |
|---|---|---|
@ -191,193 +302,154 @@ Load-bearing pixel values for the main chat composer + thread-drawer composer (b
| Card outer padding | `6px / 16px` (vertical / horizontal) | `RoomView.css.ts``.ChatComposer .Editor` |
| Textarea vertical padding | 13px (folds default — do NOT override) | `Editor.css.ts``EditorTextarea` |
| Textarea horizontal padding | 12px left, 12px right | `RoomView.css.ts``:first-child` / `:last-child` rules |
| Placeholder paddingTop | 13px (folds default — must match textarea padding) | `Editor.css.ts``EditorPlaceholderTextVisual` |
| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` `bottom` slot |
| Placeholder paddingTop | 13px (must match textarea padding) | `Editor.css.ts``EditorPlaceholderTextVisual` |
| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` bottom slot |
| IconButton size | 32×32 (folds `size="300"`, `fill="None"`) | `RoomInput.tsx` |
| IconButton internal padding | 4px (SVG 24×24 centered) | folds default |
| Empty-state composer height (single-line, no reply) | ~93px | derived |
**Don't override the textarea's vertical padding (13px) without also retuning `EditorPlaceholderTextVisual.paddingTop` in lockstep**: folds tuned the pair so Slate's placeholder span and the typed-text caret land on the same y inside the contenteditable content-box. Diverging the two breaks vertical alignment — typed text and the «Send a message…» placeholder appear at different baselines.
`RoomView.css.ts` also defines `ComposerDesktopClamp` (maxWidth 75%, centered on desktop) and `ComposerOverlay` (absolute slide/fade wrapper, `prefers-reduced-motion`-gated) — the composer is positioned as a bottom-stuck overlay reporting its height to `RoomTimeline`, and is **unmounted entirely when the thread drawer is open** (single-Slate-at-a-time).
**Visual alignment goal** — text glyph and Plus icon-glyph sit on the same vertical column at 28px from the card edge (mirrored on the right for Send):
- `text-glyph-x = outer (16) + textarea paddingLeft (12) = 28`
- `icon-glyph-x = outer (16) + row paddingLeft (8) + button-internal-pad (4) = 28`
**Bottom-left curve clearance** (Plus IconButton container vs the 32px corner):
- `button-bottom y = 6 (outer) + 4 (row pad-bot) = 10`
- `curve-x at y=10 = 32 √(32² 22²) ≈ 8.76px`
- `button-left = 16 (outer) + 8 (row pad-left) = 24`
- **clearance ≈ 15.24px** — comfortable for the hit-box; the visible glyph clears the curve by ~23px
**Top-left curve clearance** (placeholder text glyph):
- `text-glyph-y = 6 (outer) + 13 (textarea pad-top) = 19`
- `curve-x at y=19 = 32 √(32² 13²) ≈ 2.76px`
- `text-glyph-x = 28`
- **clearance ≈ 25.24px** — very generous; supports multi-line growth
**Future compactness levers** (if needed without breaking alignment):
- Outer card vertical padding (currently 6px) — drop to 4px saves 4px
- Action-row padding (currently 2/4) — drop to 0/2 saves 4px
- IconButton size (currently 300 / 32px) — already smallest in folds; no further reduction available
Avoid touching textarea or placeholder vertical padding unless you re-tune both in matched pairs and visually verify glyph alignment.
**Don't apply these to other composers**: the textarea-padding compact override is scoped to `.ChatComposer`. The message-edit overlay, `Editor.preview.tsx`, and any future `CustomEditor` consumer outside the chat composer keep the folds-default `padding: 13px 1px` (`Editor.css.ts:24-42`).
If you re-tune any number here, update both the CSS comments in `RoomView.css.ts` and this table — they're cross-referenced.
**Don't override the textarea's vertical padding (13px) without retuning `EditorPlaceholderTextVisual.paddingTop` in lockstep** — folds tuned the pair so the placeholder span and typed-text caret land on the same y. The textarea-padding compact override is scoped to `.ChatComposer`; the message-edit overlay and `Editor.preview.tsx` keep the folds-default `padding: 13px 1px`. If you re-tune any number here, update both the CSS comments and this table.
## Responsive design
**No CSS media queries** for layout. Responsive behaviour goes through `hooks/useScreenSize.ts`:
Layout responsiveness goes through `hooks/useScreenSize.ts` (NOT CSS layout media queries):
```ts
ScreenSize.Mobile // ≤750px
ScreenSize.Tablet // >750 ≤1124px
ScreenSize.Mobile // ≤750px (MOBILE_BREAKPOINT = 750)
ScreenSize.Tablet // >750 ≤1124px (TABLET_BREAKPOINT = 1124)
ScreenSize.Desktop // >1124px
```
`useScreenSizeContext()` returns the current size; components branch their JSX on it.
`useScreenSizeContext()` returns the current size (observed on `document.body`; `ScreenSizeProvider` mounted in `App.tsx`); components branch their JSX on it.
`pages/MobileFriendly.tsx` provides two wrappers:
- `MobileFriendlyPageNav(path)` hides the per-tab PageNav on Mobile unless the URL matches the exact root path.
- Mobile **listing** nav (Direct/Channels/Bots) is driven by `components/mobile-tabs-pager/` (swipe pager, native only).
- Mobile room **side panels** (members / profile / media / settings) render as bottom-up **curtain horseshoes**; desktop/tablet render them as resizable right-side panes.
- **`MobileFriendlyClientNav`** — hides global `SidebarNav` on Mobile unless URL matches a top-level route (`/home/`, `/direct/`, `/{spaceId}/`, `/explore/`, `/inbox/`)
- **`MobileFriendlyPageNav`** — hides per-tab `PageNav` (e.g. the Direct chat list) on Mobile unless URL matches the exact root path (e.g. exactly `/direct/`)
Result: on Mobile, navigating to a room hides both nav levels — only RoomView is visible. Back-button via `useRoomNavigate` collapses these into the back-stack via `history.replace` (commit dce6be9).
The only CSS `@media` queries in the app target `(hover: hover) and (pointer: fine)` to disable hover-only effects on touch devices.
> **Caveat:** the old claim "the only `@media` queries target `(hover: hover)`" is **false**. Layout is JS-driven, but the codebase does use `@media`: `(prefers-reduced-motion: reduce)` (RoomView/RoomTimeline/HorseshoeContainer/…), `(prefers-color-scheme: light)` (`index.css` light cold-start fallback), `(hover: hover) and (pointer: fine)` hover-gates (Sidebar/Channel.css), and a few `max-width`/`max-height` queries inside `BotShell.css.ts` and `auth/styles.css.ts`.
## Matrix SDK Patterns
```tsx
const mx = useMatrixClient(); // Get SDK instance
const mx = useMatrixClient(); // SDK instance (throws if no provider)
const room = useRoom(); // Current room
const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state
const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context (member-count===2)
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state (default stateKey '')
const powerLevels = usePowerLevels(room); // Permissions
```
`isOneOnOneRoom(room)` (`utils/room.ts`) = `!isSpace(room) && room.getInvitedAndJoinedMemberCount() === 2`. `useIsOneOnOneRoom(room)` is the reactive wrapper (subscribes `RoomStateEvent.Members`) that feeds each route's `IsOneOnOneProvider`.
## i18n
i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, organised by namespace (`Direct`, `Room`, `Settings`, `Push`, …).
i18next + `react-i18next`. **Single-file locales**: [`public/locales/en.json`](../../public/locales/en.json) + [`public/locales/ru.json`](../../public/locales/ru.json) (loadPath `…/public/locales/{{lng}}.json`**no per-namespace directories**). Namespaces are **top-level keys** in each file: `Organisms, App, Boot, Auth, Settings, Search, Home, Direct, Channels, Call, Room, Inbox, Explore, Create, RoomSettings, Push, Bots, User, Share`. (`App`, `Channels`, `Call`, `Bots`, `User`, `Share` are recent; `Home` is post-redesign cruft, `Inbox` survives for notification-card previews.)
- **`Push` namespace is special**: `scripts/gen-push-strings.mjs` (run as a Gradle task, commit 19d3dc0) reads it and emits `android/app/src/main/res/values{,-ru}/push_strings.xml` for Android lockscreen. **Don't put non-push UI keys in `Push`.** UI strings → `Direct`/`Room`/`User`/etc.
- Web SW push falls back to **EN** if a key is missing in RU. Always add EN + RU in the same commit.
- Russian-language quality: see `i18n.md` for tone, register, verb-vs-noun rules.
- `supportedLngs: ['en','ru']`, `fallbackLng: 'en'`, detection `['navigator','htmlTag']` with `caches:[]` (tracks system language each launch — no in-app selector).
- **`Push` namespace is special**: `scripts/gen-push-strings.mjs` (Gradle task) reads it and emits `android/.../values{,-ru}/push_strings.xml` for the Android lockscreen. Don't put non-push UI keys in `Push`. Web SW push falls back to **EN** if a key is missing in RU — always add EN + RU together.
- Russian-language quality: see `i18n.md`.
## Key Libraries
- **React 18.2** + React Router DOM 6
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail)
- **folds 2.6** — UI component library
- **jotai 2.6** — State management
- **vanilla-extract** — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` var reshades through the cascade. Adding new tokens still requires a rebuild.
- **slate 0.123** — Rich text editor
- **@tanstack/react-query 5** — Data fetching
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
- **i18next 23 + react-i18next 15** — Localisation
- **Capacitor 8.3** — Native Android wrapper
- **@capacitor/browser 8.0** — External link handling in native
- **vite-plugin-pwa** — Custom service worker via `injectManifest` strategy with `injectionPoint: undefined` (`vite.config.js:132-140`). **No automatic Workbox precache** — the SW is hand-written in `src/sw.ts`, vite-pwa just builds and registers it. There is no precache size limit to worry about; new font assets are loaded on demand via fetch, not bundled into a precache manifest.
- **`@fontsource/inter`** in `dependencies`. Other `@fontsource/*` additions must also go in `dependencies` (not dev) to land in production bundle.
## Git
- Main branch: `dev`
- Current vojo work branch: `vojo/dev`
- Semantic-release on `dev` branch
- CI: GitHub Actions (build, deploy, docker, netlify)
- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but blocked by `--max-warnings 0`. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule.
- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer
- **React 18.2** + **React Router DOM 6.30.3**
- **matrix-js-sdk 41.4.0** — exact pin (see `docs/plans/matrix_js_sdk_upgrade.md`)
- **folds 2.6.2** — UI component library (peerDep pins React 17 — see `bugs.md`)
- **jotai 2.6.0** — state
- **vanilla-extract** — type-safe CSS (compile-time tokens; theme switch is live via body-class swap)
- **slate 0.123** — rich text editor
- **@tanstack/react-query 5** — data fetching; **@tanstack/react-virtual 3** — list virtualization (NOT used by RoomTimeline)
- **i18next 23 + react-i18next 15** — localisation
- **Capacitor 8.3** + **@capacitor/browser 8.0** — native Android
- **vite-plugin-pwa**`injectManifest` strategy with `injectionPoint: undefined`**no Workbox precache**; the SW is the hand-written `src/sw.ts`.
- **`@fontsource/inter`** + **`@fontsource-variable/jetbrains-mono`** in `dependencies` (other `@fontsource/*` must also go in deps to land in the bundle).
- Newer notable deps: **`@atlaskit/pragmatic-drag-and-drop`** (lobby/space reordering), **`matrix-widget-api 1.17`** (bot widget driver + call embed), **`@element-hq/element-call-embedded 0.16.3`** (call widget — its ~9.5MB blur wasm is stripped at copy time, DM calls are voice-only), **`chroma-js`** (`plugins/color.ts`), **`react-aria 3.29`** (message + nav row interactions), **`electron` + `electron-builder`** (desktop, see `electron.md`).
## Build & deploy
- Web: `npm run build``dist/``scp` to `~/vojo/cinny/` on the production VPS (Caddy serves it)
- Android debug: `npm run build:android:debug` (composite of `npm run build && npm run android:sync && cd android && ./gradlew assembleDebug`)
- Android release / AAB: similar but `assembleRelease` / `bundleRelease`
- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy
- Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see `android.md`
- **Web (local)**: `npm run build``dist/``rsync`/`scp` to `~/vojo/cinny/` on the VPS (Caddy serves it). VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates it. `Deploy widgets` builds the three Preact bot-widget apps.
- **Web (CI)**: `.github/workflows/prod-deploy.yml``workflow_dispatch` only → `git describe --tags` → build → Netlify production deploy → GPG-signed `tar.gz` GitHub release → multi-arch Docker push (Docker Hub + GHCR). Other workflows: `build-pull-request`, `deploy-pull-request`, `docker-pr`, `netlify-dev`, `lockfile`, `pr-title`.
- **Android**: `npm run build:android:debug` (build → strip-sourcemaps → `cap sync``gradlew assembleDebug`); release/AAB analogues. App version from `git describe --tags --match 'v*'` mirrored in `vite.config.js::resolveAppVersion()` and `android/app/build.gradle`. See `android.md`.
- **Electron**: `npm run build:electron:win` (or `:win:docker` from WSL). See `electron.md`.
- Build tooling: Node engine `>=22.12.0`, but `.node-version` pins **24.13.1** (used by CI) — match it locally. Vite manual chunks split emoji-data (~506KB), matrix-sdk, editor; react/folds/react-aria deliberately not split (boot-critical wasm/top-level-await reorder risk). `package.json` `version` field (`0.2.0`) is stale relative to git tags — the runtime version comes from `git describe`.
## Git
- **Upstream Cinny** main branch is `dev`; **Vojo** work branch is **`vojo/dev`** (the `origin` remote, `git.vojo.chat`). Local `dev` tracks stale upstream Cinny. PRs target **`main`** per the working environment.
- **No semantic-release** — releases are **tag-driven** via the manual `prod-deploy.yml` workflow.
- **Android `versionCode` is monotonic**: `major*1_000_000 + minor*1_000 + patch`, where `patch` = commit count since the `v*` tag. Don't squash/rebase across release boundaries — Play store rejects downgrades.
- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green. `no-explicit-any` / `no-non-null-assertion` are `'warn'` but blocked by `--max-warnings 0`; when unavoidable (matrix-js-sdk boundary, generic helper, third-party callback), add an inline `// eslint-disable-next-line` with a one-line justification rather than relaxing the rule.
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer.
## Refactor checklist for AI agents
Encoded from P3c retrospective (2026-04-28) — three classes of bug that ate
Encoded from the P3c retrospective (2026-04-28) — three classes of bug that ate
three rounds of code review. Hit this checklist **before** declaring a
refactor done; each item is cheap to verify and would have caught a real
BLOCKER if applied earlier in P3c.
BLOCKER if applied earlier.
### 1. Plan-trust = trust-but-verify
When the plan says "X is automatic" or "Y migrates correctly," **don't
When a plan says "X is automatic" or "Y migrates correctly," **don't
believe it without grepping the affected subsystem**. Plans encode
intentions; the actual code may have load-bearing assumptions the plan
didn't audit.
- For each utterance of «handle Y по новому пути», `grep -rn` for the OLD
path (atom, helper, gate function) and ensure no lingering callsites read
it with the OLD semantic.
- For each "handle Y по новому пути", `grep -rn` for the OLD path (atom,
helper, gate function) and ensure no lingering callsites read it with the
OLD semantic.
- Especially for **load-bearing surfaces** (calls, push, FSI, edge-to-edge,
service worker, native bridges): the plan §7 «не трогаем» list is a
**hint that the surface contains hidden coupling**, not a license to
ignore it. If your refactor changes _any_ visibility/classification gate
that touches calls or push, walk every call/push hook by hand.
service worker, native bridges): a plan's "не трогаем" list is a **hint
that the surface contains hidden coupling**, not a license to ignore it.
If your refactor changes _any_ visibility/classification gate that touches
calls or push, walk every call/push hook by hand.
P3c blocker example: plan §6.8 said «DmCallButton после P3c гейтится на
useIsOneOnOne()» without auditing `useIncomingRtcNotifications` /
`useCallerAutoHangup` which still gated on `m.direct`. Three rounds of
review later we caught it. Cost: one wasted round.
P3c blocker example: plan §6.8 said "DmCallButton gated on `useIsOneOnOne()`"
without auditing `useIncomingRtcNotifications` / `useCallerAutoHangup` which
still gated on `m.direct`. Caught three rounds later.
### 2. Systematic consumer audit before renaming/repurposing
Before changing the **semantic** of a hook, atom, or context-value (not
just renaming — semantic shift), run a `grep -rn` for every consumer and
just renaming — semantic shift), run `grep -rn` for every consumer and
classify each:
```
grep -rn "useFoo\|fooAtom\|FooContext" src/
```
For each callsite, answer:
- (a) Does the new semantic still match this callsite's intent?
- (b) Does the callsite use the symbol for ITS original semantic, or for a
DIFFERENT semantic that happens to overlap with the old name?
For each callsite: (a) Does the new semantic still match its intent? (b)
Does it use the symbol for ITS original semantic, or a DIFFERENT one that
happens to overlap with the old name?
P3c examples missed initially:
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
Direct. Mechanical rename broke the split.
- `mDirects.has(room.roomId)` in `RoomIntro` (since removed), `RoomSettings`,
`RoomProfile`, `SpaceSettings` for peer-avatar fallback — needed member-count semantic
consistent with `RoomViewHeader`. Mechanical preservation of m.direct
diverged the chrome.
P3c examples missed initially: `useDirectRooms` in `MutualRoomsChip` needed
the m.direct semantic, not universal-Direct; `mDirects.has(roomId)` in
`RoomSettings`/`RoomProfile`/`SpaceSettings` for peer-avatar fallback needed
the member-count semantic consistent with the header.
### 3. Reactivity audit for context values from mutable objects
If you put a `room.X()`-style call into a Provider's `value=`, and `room`
is the matrix-js-sdk `Room` object (mutable, fires events), **the value is
a static snapshot at first render**. Subsequent state changes won't flow
through the context until the Provider re-renders for a different reason.
through until the Provider re-renders for another reason.
Symptoms: «UI не обновляется когда X меняется», «надо перезайти в комнату
чтобы X применился».
Symptoms: "UI не обновляется когда X меняется", "надо перезайти в комнату".
Fix pattern: extract Provider into an inner component that subscribes to
the relevant matrix-js-sdk event (e.g. `RoomStateEvent.Members`,
`RoomEvent.Receipt`, `MatrixEventEvent.Decrypted`) via `useState +
useEffect`. Capture the emitter ref **inside** the effect for cleanup
leak-safety against rare snapshot replacements. Reference impl:
[`hooks/useIsOneOnOneRoom.ts`](../../src/app/hooks/useIsOneOnOneRoom.ts)
Fix pattern: extract the Provider into an inner component that subscribes to
the relevant matrix-js-sdk event (`RoomStateEvent.Members`,
`RoomEvent.Receipt`, `MatrixEventEvent.Decrypted`) via `useState + useEffect`,
capturing the emitter ref **inside** the effect for cleanup leak-safety.
Reference impl: [`hooks/useIsOneOnOneRoom.ts`](../../src/app/hooks/useIsOneOnOneRoom.ts)
+ [`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx)
(the `ResolvedRoomProvider` split pattern).
P3c blocker example: `IsOneOnOneProvider` value initially computed as
`room.getInvitedAndJoinedMemberCount() === 2` at provider mount — froze for
the entire route. Inviting a 3rd into a 1:1 didn't flip chrome until
navigation. Round-2 review caught it.
P3c blocker example: `IsOneOnOneProvider` value computed once at mount froze
for the route — inviting a 3rd into a 1:1 didn't flip chrome until navigation.
### 4. Don't defer mechanical cleanups «to P6» when one-pass is cheap
### 4. Don't defer mechanical cleanups "to a later phase" when one-pass is cheap
Each `// TODO: rename X` left in the diff multiplies into a 5-file rename
sweep later. If a prop name diverges from its semantic during refactor,
fix in the same commit if it's < 5 callsites. P6 cleanup is hopeful, not
guaranteed.
Each `// TODO: rename X` left in the diff multiplies into a multi-file rename
sweep later. If a prop name diverges from its semantic during refactor, fix
it in the same commit if it's < 5 callsites. (Live examples of deferred
renames still in tree: `RoomViewHeader.tsx``RoomViewHeaderDm` is a thin
wrapper whose `Dm` suffix is now historical; the global `SidebarNav` rail is
dead-but-mounted-nowhere awaiting `sidebar_cleanup`.)