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 # 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 ## Quick Start
```bash ```bash
npm start # dev server on :8080 npm start # dev server on :8080 (strictPort, host:true)
npm run build # production build → dist/ 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 npm run typecheck # tsc --noEmit
``` ```
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins. 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 ## Source Layout
``` ```
src/ src/
├── index.tsx # Entry point ├── index.tsx # Entry point (calls pushSessionToSW, capacitor/electron link handlers)
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used ├── colors.css.ts # darkTheme (Dawn) + lightTheme (Vojo) via createTheme(color, …) — both Vojo-owned
├── config.css.ts # fontWeight overrides ├── 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/ ├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout) │ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
│ └── secretStorageKeys.js # Crypto callbacks │ └── secretStorageKeys.js # Crypto callbacks
├── types/matrix/ # Matrix protocol types (room.ts, accountData.ts, common.ts) ├── types/matrix/ # Matrix protocol types + sdkAugmentation.d.ts
└── app/ └── app/
├── i18n.ts # i18next config ├── i18n.ts # i18next config (single-file locales)
├── pages/ ├── pages/
│ ├── App.tsx # Root component (providers, config loader) │ ├── App.tsx # Root component (ScreenSizeProvider, query client, config loader)
│ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes │ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes (~497 LOC)
│ └── MobileFriendly.tsx # MobileFriendlyClientNav + MobileFriendlyPageNav (responsive split) │ ├── 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 ├── features/ # Feature modules
├── components/ # Shared components ├── components/ # Shared components (~70 dirs)
├── hooks/ # ~117 custom hooks ├── hooks/ # ~132 custom hooks
├── state/ # Jotai atoms ├── state/ # Jotai atoms (~35 top-level files + room/, room-list/, hooks/, utils/)
├── plugins/ # Content plugins ├── plugins/ # Content plugins (call widget driver, emoji-data, color, react-prism)
├── utils/ # Utilities ├── utils/ # Utilities (room.ts, matrix.ts, time.ts, capacitor.ts, electron.ts)
└── styles/ # Vanilla-extract global styles └── 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/`) ## 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) AuthRouteThemeManager → ClientRoot → ClientInitStorageAtom →
- `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/`. ClientRoomsNotificationPreferences → ClientBindAtoms → ClientNonUIFeatures →
- `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. CallEmbedProvider → HorseshoeContainer → ClientLayout(nav={null}) → <Outlet/>
- `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
### 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 /direct/:roomIdOrAlias
→ PageRoot (nav=Direct, outlet=…) → PageRoot (nav=Direct, outlet=…)
→ DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2) → DirectRouteRoomProvider (ResolvedRoomProvider sets IsOneOnOneProvider reactively)
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer) → Room.tsx (RoomViewHeader + RoomView, screen-size branching for side panels)
→ RoomTimeline + RoomViewTyping + RoomInput → 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. ```ts
- 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. const isOneOnOne = useIsOneOnOne(); // member-count === 2 (from IsOneOnOneProvider)
- 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`. const channelsMode = useChannelsMode(); // true for any room under /channels/
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative. 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/`) ## Features (`src/app/features/`)
| Dir | Purpose | | 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/` | 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` (~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/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/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) | | `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). |
| `room-settings/` | Room-specific settings page | | `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/`. |
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools | | `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`. |
| `space-settings/` | Space-specific settings | | `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`. |
| `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.** | | `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()`. |
| `lobby/` | Space/room lobby view | | `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. |
| `search/` | Global search | | `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). |
| `message-search/` | In-room message search | | `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. |
| `create-chat/` | DM creation flow (recent commit 58ec12d split it into username + server fields) | | `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. |
| `create-room/` | Room creation | | `search/` | Global room/space switcher modal (Cmd+K style) — `Search.tsx` (mounted via `SearchModalRenderer`) + `useRoomSearch.ts` (extracted `useAsyncSearch` over directs/rooms/spaces/orphan-spaces). |
| `create-space/` | Space creation | | `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). |
| `add-existing/` | Join existing rooms | | `create-chat/` | DM creation — username + server fields (`FALLBACK_SERVER='vojo.chat'`), dedup via `getDMRoomFor`. |
| `join-before-navigate/` | Pre-join navigation logic | | `create-room/` / `create-space/` | Room/space creation (`CreateRoom`/`CreateSpace` + their modal wrappers, mounted via `*ModalRenderer`). |
| `call/` | Element Call integration. **Vojo-DM voice call lifecycle lives here** (phases 0..5.35). Don't touch unless redesigning calls. | | `add-existing/` | Add existing rooms to a space. |
| `call-status/` | Call state display + IncomingCallStrip (mounted as sibling in `Router.tsx:184`, **not** inside RoomViewHeader). | | `join-before-navigate/` | Pre-join room card (`JoinBeforeNavigate.tsx`, ~82 LOC). |
### Virtualization in features/room/ ### 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/`) ## 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 rendering — `message/`
- `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. - `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`.
- `emoji-board/` — Emoji picker - `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`.
- `image-pack-view/` — Custom emoji pack management - `message/attachment/``Attachment.tsx` + the Stream-media bubble shells (`StreamMediaShell` aspect-clamped 320px, `StreamMediaImage`, `StreamMediaVideo`).
- `image-viewer/`, `Pdf-viewer/` — Media viewers - `message/placeholder/``DefaultPlaceholder`, `LinePlaceholder`.
- `sidebar/``Sidebar.tsx` (66px wrapper), `SidebarItem.tsx`, `SidebarStack.tsx`, `SidebarContent.tsx`, `Sidebar.css.ts` - 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`.
- `user-profile/` — User info, power chips, moderation
- `member-tile/` — Member list items ### Editor — `editor/`
- `power/` — Power level UI
- `upload-card/` — Upload progress cards 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/`.
- `url-preview/` — Link previews
- `page/` — Page layout wrapper (`Page`, `PageRoot`, `PageNav`, `PageHeader`, `PageContent`, `PageHero`) ### Media / viewers
- `setting-tile/` — Settings list item pattern
- `sequence-card/`, `cutout-card/` — Card layouts - `media/` (**NEW**) — primitive `<Image>`/`<Video>` wrappers + `MediaControls` layout shell.
- `uia-stages/` — User-interactive auth stages (email, captcha, token) - `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).
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs - `emoji-board/` — emoji picker. `image-pack-view/` — custom emoji/sticker pack management.
- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
### 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) ## 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 | Notes |
|---|---|
| Path | Commits | 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/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-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. |
| `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 | | `state/callEmbed.ts`, `state/incomingCalls.ts`, `state/pendingCallAction.ts` | Call embed lifecycle, ring queue (`incomingCallsAtom` + derived `isRingingAtom`), native push-action bridge (answer/decline). |
| `features/call-status/IncomingCallStrip.tsx` | dabe5ca, 91afffc | Mounted as sibling in `Router.tsx:184` (not inside header). Decline / accept / mid-ring backgrounding handled here | | `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | Kept but not rendered — feeds `useDotColor` for the Stream rail dot. |
| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle | | `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. |
| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | 0c4cfb9 | WhatsApp checkmarks | | `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/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/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | Hardware back integration. |
| `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/useAutoDirectSync.ts` + `utils/matrix.ts` | `m.direct` sync on join (interop), skips bridged + >2-member rooms. |
| `hooks/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | dce6be9 | Hardware back integration | | `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). |
| `hooks/useAutoDirectSync.ts` + `utils/matrix.ts` | 84eeac9 | m.direct sync on join (DM rooms shown correctly for invited users) | | `pages/auth/*` | Bistable layout — don't change body/root background CSS-vars; see `bugs.md`. |
| `pages/auth/*` | c466848, e6623b2, cf5ee9a, 9b41cbb | 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`. |
| `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` | Gradle task generates `push_strings.xml` from i18n `Push` namespace. Don't put non-push keys in `Push`; add EN+RU together. |
| `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 |
## State Management ## 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. **Persisted to localStorage:**
- `sessions.ts` — Active session - `settings.ts``settingsAtom` (key `settings`; custom `getSettings`/`setSettings`, see below).
- `upload.ts` — Upload progress (in-memory) - `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).
- `room/``roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread` - `activeChannelsSpace.ts``activeChannelsSpaceAtom` (key `vojo.activeSpaceId`, stored RAW not JSON for back-compat) — Channels active workspace.
- `room-list/``roomList`, `mDirectAtom`, `inviteList`, sorting/filtering - `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.
- `callEmbed.ts``callEmbedAtom`, `callChatAtom` (call lifecycle)
- `closedNavCategories.ts``closedNavCategoriesAtom` (collapsed/expanded folders)
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 ## 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/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`.
- `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). - `hooks/useTheme.ts`: `DarkTheme.classNames = ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark']`, `LightTheme` mirror. `useActiveTheme()` picks on `useSystemTheme` + `themeId`.
- 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. - `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 `Primary.Main = #5b6aff` (indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe. - Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `#5b6aff` (indigo).
- 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. - 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 ### 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 system bars**`MainActivity.java::onCreate` hardcodes `setAppearanceLight{Status,Navigation}Bars(false)`.
- **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. - **Android native splash**`res/values/colors.xml::splash_bg = #0d0e11` + `styles.xml::windowBackground`; no `values-night/`.
- **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. - **Capacitor WebView paint**`capacitor.config.ts::android.backgroundColor = '#0d0e11'`.
- **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. - **PWA manifest**`public/manifest.json` `theme_color`/`background_color` = `#0d0e11` (no media-query support).
- **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. - **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` 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. - **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.
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`.
## Composer card geometry ## 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 | | 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` | | 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 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 | | 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` | | Placeholder paddingTop | 13px (must match textarea padding) | `Editor.css.ts``EditorPlaceholderTextVisual` |
| Action-row padding | `2px / 8px / 4px` (top / sides / bottom) | `RoomInput.tsx` `bottom` slot | | 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 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): **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.
- `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.
## Responsive design ## 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 ```ts
ScreenSize.Mobile // ≤750px ScreenSize.Mobile // ≤750px (MOBILE_BREAKPOINT = 750)
ScreenSize.Tablet // >750 ≤1124px ScreenSize.Tablet // >750 ≤1124px (TABLET_BREAKPOINT = 1124)
ScreenSize.Desktop // >1124px 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/`) > **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`.
- **`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.
## Matrix SDK Patterns ## Matrix SDK Patterns
```tsx ```tsx
const mx = useMatrixClient(); // Get SDK instance const mx = useMatrixClient(); // SDK instance (throws if no provider)
const room = useRoom(); // Current room const room = useRoom(); // Current room
const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context (member-count===2)
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state const stateEvent = useStateEvent(room, StateEvent.Type); // Room state (default stateKey '')
const powerLevels = usePowerLevels(room); // Permissions 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 ## 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. - `supportedLngs: ['en','ru']`, `fallbackLng: 'en'`, detection `['navigator','htmlTag']` with `caches:[]` (tracks system language each launch — no in-app selector).
- Web SW push falls back to **EN** if a key is missing in RU. Always add EN + RU in the same commit. - **`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` for tone, register, verb-vs-noun rules. - Russian-language quality: see `i18n.md`.
## Key Libraries ## Key Libraries
- **React 18.2** + React Router DOM 6 - **React 18.2** + **React Router DOM 6.30.3**
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for the M0..M4 bump trail) - **matrix-js-sdk 41.4.0** — exact pin (see `docs/plans/matrix_js_sdk_upgrade.md`)
- **folds 2.6** — UI component library - **folds 2.6.2** — UI component library (peerDep pins React 17 — see `bugs.md`)
- **jotai 2.6** — State management - **jotai 2.6.0** — state
- **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. - **vanilla-extract** — type-safe CSS (compile-time tokens; theme switch is live via body-class swap)
- **slate 0.123** — Rich text editor - **slate 0.123** — rich text editor
- **@tanstack/react-query 5** — Data fetching - **@tanstack/react-query 5** — data fetching; **@tanstack/react-virtual 3** — list virtualization (NOT used by RoomTimeline)
- **@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
- **i18next 23 + react-i18next 15** — Localisation - **Capacitor 8.3** + **@capacitor/browser 8.0** — native Android
- **Capacitor 8.3** — Native Android wrapper - **vite-plugin-pwa**`injectManifest` strategy with `injectionPoint: undefined`**no Workbox precache**; the SW is the hand-written `src/sw.ts`.
- **@capacitor/browser 8.0** — External link handling in native - **`@fontsource/inter`** + **`@fontsource-variable/jetbrains-mono`** in `dependencies` (other `@fontsource/*` must also go in deps to land in the bundle).
- **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. - 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`).
- **`@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
## Build & deploy ## Build & deploy
- Web: `npm run build``dist/``scp` to `~/vojo/cinny/` on the production VPS (Caddy serves it) - **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.
- Android debug: `npm run build:android:debug` (composite of `npm run build && npm run android:sync && cd android && ./gradlew assembleDebug`) - **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 release / AAB: similar but `assembleRelease` / `bundleRelease` - **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`.
- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy - **Electron**: `npm run build:electron:win` (or `:win:docker` from WSL). See `electron.md`.
- Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see `android.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 ## 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 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 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 ### 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 believe it without grepping the affected subsystem**. Plans encode
intentions; the actual code may have load-bearing assumptions the plan intentions; the actual code may have load-bearing assumptions the plan
didn't audit. didn't audit.
- For each utterance of «handle Y по новому пути», `grep -rn` for the OLD - For each "handle Y по новому пути", `grep -rn` for the OLD path (atom,
path (atom, helper, gate function) and ensure no lingering callsites read helper, gate function) and ensure no lingering callsites read it with the
it with the OLD semantic. OLD semantic.
- Especially for **load-bearing surfaces** (calls, push, FSI, edge-to-edge, - Especially for **load-bearing surfaces** (calls, push, FSI, edge-to-edge,
service worker, native bridges): the plan §7 «не трогаем» list is a service worker, native bridges): a plan's "не трогаем" list is a **hint
**hint that the surface contains hidden coupling**, not a license to that the surface contains hidden coupling**, not a license to ignore it.
ignore it. If your refactor changes _any_ visibility/classification gate If your refactor changes _any_ visibility/classification gate that touches
that touches calls or push, walk every call/push hook by hand. calls or push, walk every call/push hook by hand.
P3c blocker example: plan §6.8 said «DmCallButton после P3c гейтится на P3c blocker example: plan §6.8 said "DmCallButton gated on `useIsOneOnOne()`"
useIsOneOnOne()» without auditing `useIncomingRtcNotifications` / without auditing `useIncomingRtcNotifications` / `useCallerAutoHangup` which
`useCallerAutoHangup` which still gated on `m.direct`. Three rounds of still gated on `m.direct`. Caught three rounds later.
review later we caught it. Cost: one wasted round.
### 2. Systematic consumer audit before renaming/repurposing ### 2. Systematic consumer audit before renaming/repurposing
Before changing the **semantic** of a hook, atom, or context-value (not 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: classify each:
``` ```
grep -rn "useFoo\|fooAtom\|FooContext" src/ grep -rn "useFoo\|fooAtom\|FooContext" src/
``` ```
For each callsite, answer: For each callsite: (a) Does the new semantic still match its intent? (b)
- (a) Does the new semantic still match this callsite's intent? Does it use the symbol for ITS original semantic, or a DIFFERENT one that
- (b) Does the callsite use the symbol for ITS original semantic, or for a happens to overlap with the old name?
DIFFERENT semantic that happens to overlap with the old name?
P3c examples missed initially: P3c examples missed initially: `useDirectRooms` in `MutualRoomsChip` needed
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split the m.direct semantic, not universal-Direct; `mDirects.has(roomId)` in
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal- `RoomSettings`/`RoomProfile`/`SpaceSettings` for peer-avatar fallback needed
Direct. Mechanical rename broke the split. the member-count semantic consistent with the header.
- `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.
### 3. Reactivity audit for context values from mutable objects ### 3. Reactivity audit for context values from mutable objects
If you put a `room.X()`-style call into a Provider's `value=`, and `room` 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 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 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 меняется», «надо перезайти в комнату Symptoms: "UI не обновляется когда X меняется", "надо перезайти в комнату".
чтобы X применился».
Fix pattern: extract Provider into an inner component that subscribes to Fix pattern: extract the Provider into an inner component that subscribes to
the relevant matrix-js-sdk event (e.g. `RoomStateEvent.Members`, the relevant matrix-js-sdk event (`RoomStateEvent.Members`,
`RoomEvent.Receipt`, `MatrixEventEvent.Decrypted`) via `useState + `RoomEvent.Receipt`, `MatrixEventEvent.Decrypted`) via `useState + useEffect`,
useEffect`. Capture the emitter ref **inside** the effect for cleanup capturing the emitter ref **inside** the effect for cleanup leak-safety.
leak-safety against rare snapshot replacements. Reference impl: Reference impl: [`hooks/useIsOneOnOneRoom.ts`](../../src/app/hooks/useIsOneOnOneRoom.ts)
[`hooks/useIsOneOnOneRoom.ts`](../../src/app/hooks/useIsOneOnOneRoom.ts)
+ [`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx) + [`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx)
(the `ResolvedRoomProvider` split pattern). (the `ResolvedRoomProvider` split pattern).
P3c blocker example: `IsOneOnOneProvider` value initially computed as P3c blocker example: `IsOneOnOneProvider` value computed once at mount froze
`room.getInvitedAndJoinedMemberCount() === 2` at provider mount — froze for for the route — inviting a 3rd into a 1:1 didn't flip chrome until navigation.
the entire route. Inviting a 3rd into a 1:1 didn't flip chrome until
navigation. Round-2 review caught it.
### 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 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, sweep later. If a prop name diverges from its semantic during refactor, fix
fix in the same commit if it's < 5 callsites. P6 cleanup is hopeful, not it in the same commit if it's < 5 callsites. (Live examples of deferred
guaranteed. 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`.)