vojo/docs/ai/architecture.md

456 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (strictPort, host:true)
npm run build # production build → dist/
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` 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 (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 + sdkAugmentation.d.ts
└── app/
├── i18n.ts # i18next config (single-file locales)
├── pages/
│ ├── 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 (~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)
apps/ai-bot/ # Go Synapse appservice — "Vojo AI" (@ai), xAI-Grok backend (server-side, NOT client; see its README + server-side.md)
```
## Pages & Routing (`src/app/pages/`)
Router in `Router.tsx::createRouter(clientConfig, screenSize)`. All authed routes hang
off one big `<Route>` whose `element` is the provider stack:
```
AuthRouteThemeManager → ClientRoot → ClientInitStorageAtom →
ClientRoomsNotificationPreferences → ClientBindAtoms → ClientNonUIFeatures →
CallEmbedProvider → HorseshoeContainer → ClientLayout(nav={null}) → <Outlet/>
```
**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 (ResolvedRoomProvider sets IsOneOnOneProvider reactively)
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for side panels)
→ RoomTimeline + RoomTimelineTyping + RoomInput
```
The timeline picks a layout via a single **member-count** check (Element-Web's tier-2 pattern). The authoritative decision lives in `RoomTimeline.tsx`:
```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';
```
- **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.
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** (~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; 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 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, **per side**: OWN dots are **white** (`--vojo-stream-name-own`, matching the own nick), except **green=read & not yet answered** (prominent — demotes back to white once the peer replies, tracked reactively via `RoomEvent.Timeline`); **PEER (incoming) dots are gray** (`--vojo-dot-neutral`); gold=mention / red=failed override either side. Nicks: own=white, peer=brand purple), `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)
| 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/` (~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.
**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.
**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**.
- `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 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 `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). 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 |
|---|---|---|
| Card corner radius | 32px | `VOJO_HORSESHOE_RADIUS_PX` |
| 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 (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` |
`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).
**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
Layout responsiveness goes through `hooks/useScreenSize.ts` (NOT CSS layout media queries):
```ts
ScreenSize.Mobile // ≤750px (MOBILE_BREAKPOINT = 750)
ScreenSize.Tablet // >750 ≤1124px (TABLET_BREAKPOINT = 1124)
ScreenSize.Desktop // >1124px
```
`useScreenSizeContext()` returns the current size (observed on `document.body`; `ScreenSizeProvider` mounted in `App.tsx`); components branch their JSX on it.
- `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.
> **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(); // SDK instance (throws if no provider)
const room = useRoom(); // Current room
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`. **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.)
- `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.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 (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 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.
### 1. Plan-trust = trust-but-verify
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 "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): 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 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 `grep -rn` for every consumer and
classify each:
```
grep -rn "useFoo\|fooAtom\|FooContext" src/
```
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` 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 until the Provider re-renders for another reason.
Symptoms: "UI не обновляется когда X меняется", "надо перезайти в комнату".
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 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 a later phase" when one-pass is cheap
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`.)