vojo/docs/ai/architecture.md

26 KiB
Raw Blame History

Architecture

Quick Start

npm start         # dev server on :8080
npm run build     # production build → dist/
npm run lint      # eslint + prettier
npm run typecheck # tsc --noEmit

Build: Vite 5.4 with vanilla-extract, WASM, PWA plugins.

Note: .husky/pre-commit is currently commented out. npm run check:eslint is green (0 errors, 116 warnings — kept as warn for no-explicit-any/no-non-null-assertion policy). npm run typecheck still has ~32 known errors (residual project bugs after the TS 5.4 + Bundler migration that cleared ~800 module-resolution errors — see docs/known-tech-debt-lint/). Use bash docs/known-tech-debt-lint/diff.sh to verify your changes added no new typecheck errors, then npm run build for the green build check.

Source Layout

src/
├── index.tsx                    # Entry point
├── colors.css.ts                # Custom dark-theme via createTheme(color, …); no light override (uses folds.lightTheme as-is)
├── config.css.ts                # fontWeight overrides
├── client/
│   ├── initMatrix.ts            # Matrix SDK init (createClient, startClient, logout)
│   └── secretStorageKeys.js     # Crypto callbacks
├── types/matrix/                # Matrix protocol types (room.ts, accountData.ts, common.ts)
└── app/
    ├── i18n.ts                  # i18next config
    ├── pages/
    │   ├── App.tsx              # Root component (providers, config loader)
    │   ├── Router.tsx           # createBrowserRouter / createHashRouter, all routes
    │   └── MobileFriendly.tsx   # MobileFriendlyClientNav + MobileFriendlyPageNav (responsive split)
    ├── features/                # Feature modules
    ├── components/              # Shared components
    ├── hooks/                   # ~117 custom hooks
    ├── state/                   # Jotai atoms
    ├── plugins/                 # Content plugins
    ├── utils/                   # Utilities
    └── styles/                  # Vanilla-extract global styles

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.

  • Auth (auth/login/, auth/register/, auth/reset-password/) — NOTE: bistable layout fragility, see bugs.md. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. Don't change body/#root background CSS-vars — auth uses computed sizes for safe-area painting.
  • Client (client/) — main layout after login (wrapped by ClientLayout = nav + content)
    • home/Redirect-only shim after P3c. HomeRouteRoomProvider redirects /home/{roomId}/ to /direct/{roomId}/ so cold-start push deep links and pre-P3c bookmarks resolve. No Home page or PageNav exists. The /home/_create, /home/_join, /home/_search routes redirect to /direct/.
    • direct/ — Universal room list (path: /direct/). After P3c contains every joined «orphan» non-space room (1:1 DMs, group DMs, group rooms, bridged chats, anything that used to live in /home/) plus every m.direct-tagged non-space room — implementation-wise useOrphanRooms useDirects, see useDirectRooms.ts for the full union semantics. Non-m.direct rooms that are space children stay only in the parent space tab — they are not duplicated in /direct/. See dm_1x1_redesign.md §6.8.
    • space/ — Space view (path: /:spaceIdOrAlias/). Spaces (= future Channels) keep their own tab and child-room route; they render Stream-style timelines too.
    • explore/ — Public rooms (path: /explore/)
    • inbox/ — Notifications, invites (path: /inbox/)
    • create/ — New room/space (path: /create/)
    • sidebar/ — Tab components (DirectTab, SpaceTabs, ExploreTab, CreateTab, SearchTab, UnverifiedTab, InboxTab, SettingsTab). The legacy HomeTab was removed in P3c.
    • WelcomePage.tsx — Empty state (vojo SVG + version). Mobile is intentionally null at the index route ({mobile ? null : <Route index element={<WelcomePage />} />}) — by design, not a bug.
    • SidebarNav.tsx — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up sidebar_cleanup plan once the new Direct/Channels surfaces are self-sufficient.
    • SyncIndicator.tsx, SpecVersions.tsx — Connection status. SyncIndicator is the bottom-edge glow line (green animated while syncing, frozen red on SyncState.Error); replaced the upstream Cinny SyncStatus top banner whose previous !== Syncing clear-condition could hold "Connecting..." for the full 30s long-poll. Render is no longer gated on PreparedMatrixClientProvider mounts as soon as mx is created, the indicator carries the loading affordance.

Universal Stream routing + DM classification (post-P3c)

/direct/:roomIdOrAlias
  → PageRoot (nav=Direct, outlet=…)
    → DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2)
      → Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
        → RoomTimeline + RoomViewTyping + RoomInput

After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single member-count check, mirroring Element-Web's tier-2 pattern (room.getInvitedAndJoinedMemberCount() === 2):

  • 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in useRoomAvatar(room, isOneOnOne)), the DmCallButton, and unconditionally hide membership/nick/avatar syslines.
  • Group rooms (member-count > 2) get the room-style header (no peer fallback), no DmCallButton, and respect the hideMembershipEvents / hideNickAvatarEvents user settings for syslines.
  • Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.

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.

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.

Features (src/app/features/)

Dir Purpose
room/ Core room view — RoomTimeline.tsx (~1700 LOC after P3c collapse), RoomInput.tsx (~691 LOC), RoomViewHeader.tsx (thin wrapper after P4) → RoomViewHeaderDm.tsx (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via N members line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the menu), MembersDrawer (suppressed for 1:1 in Room.tsx), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete
room/message/ Message.tsx (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no isStream gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy Compact/Bubble layouts and MessageLayout enum are gone; Modern.tsx survives only as a card-preview layout for pin-menu / message-search / inbox.
room-nav/ RoomNavItem.tsx (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (useCallSession, useCallMembers, useCallStart)
room-settings/ Room-specific settings page
common-settings/ Shared settings: general, members, permissions, emojis-stickers, developer-tools
space-settings/ Space-specific settings
settings/ User settings (general, account, notifications, devices, emojis, about, dev-tools). MessageLayout / messageSpacing / legacyUsernameColor were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. hideMembershipEvents / hideNickAvatarEvents survive — they still gate the group-room syslines. Logout lives here only.
lobby/ Space/room lobby view
search/ Global search
message-search/ In-room message search
create-chat/ DM creation flow (recent commit 58ec12d split it into username + server fields)
create-room/ Room creation
create-space/ Space creation
add-existing/ Join existing rooms
join-before-navigate/ Pre-join navigation logic
call/ Element Call integration. Vojo-DM voice call lifecycle lives here (phases 0..5.35). Don't touch unless redesigning calls.
call-status/ Call state display + IncomingCallStrip (mounted as sibling in Router.tsx:184, not inside RoomViewHeader).

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.)

Key Components (src/app/components/)

  • message/ — Message rendering. Only Stream.tsx and Modern.tsx layouts ship after P3c (Compact/Bubble deleted along with MessageLayout enum). Every timeline row uses <StreamLayout/>. Modern.tsx survives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines. EventContent.tsx is a single-branch sysline renderer (the legacy IsStreamProvider context was deleted; rail metadata flows in via railStart / railEnd props directly).
  • message/MessageStatus.tsx + hooks/useMessageStatus.tsKept but no longer rendered in the timeline. P3c retired the WhatsApp-style checkmarks because the Stream-rail dot now encodes the same delivery / read state via colour and opacity. useMessageStatus is still consumed inside useDotColor.ts, so the file is load-bearing.
  • editor/ — Slate-based rich text editor. Autocomplete: RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, CommandAutocomplete. Editor.tsx is the Slate root — preserve.
  • emoji-board/ — Emoji picker
  • image-pack-view/ — Custom emoji pack management
  • image-viewer/, Pdf-viewer/ — Media viewers
  • sidebar/Sidebar.tsx (66px wrapper), SidebarItem.tsx, SidebarStack.tsx, SidebarContent.tsx, Sidebar.css.ts
  • user-profile/ — User info, power chips, moderation
  • member-tile/ — Member list items
  • power/ — Power level UI
  • upload-card/ — Upload progress cards
  • url-preview/ — Link previews
  • page/ — Page layout wrapper (Page, PageRoot, PageNav, PageHeader, PageContent, PageHero)
  • setting-tile/ — Settings list item pattern
  • sequence-card/, cutout-card/ — Card layouts
  • uia-stages/ — User-interactive auth stages (email, captcha, token)
  • room-intro/ — Room introduction card
  • invite-user-prompt/, join-address-prompt/, leave-room-prompt/ — Dialogs
  • BackRouteHandler.tsx — Web back-button → back-stack collapse via replace (commit dce6be9)

Vojo-specific code paths (preserve when redesigning)

These are vojo additions on top of stock Cinny — they crossed many recent stabilization commits and are now load-bearing.

Path Commits Notes
features/room/RoomViewHeaderDm.tsx::DmCallButton calls phases 0-5.35 Outgoing DM voice call entry point. Gated on useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room) after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (useIncomingRtcNotifications, useCallerAutoHangup) which still gate ring delivery and caller-side auto-hangup on m.direct, (3) explicit MSC2346 m.bridge exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan.
features/call/* (CallEmbed, CallView) 91afffc, e8188d7, fab533e Element Call widget; don't unmount/remount the widget root on header redesign — Android FGS is keyed on joined state
features/call-status/IncomingCallStrip.tsx dabe5ca, 91afffc Mounted as sibling in Router.tsx:184 (not inside header). Decline / accept / mid-ring backgrounding handled here
state/callEmbed.ts (callEmbedAtom, callChatAtom) call phases Embed lifecycle
components/message/MessageStatus.tsx + hooks/useMessageStatus.ts 0c4cfb9 WhatsApp checkmarks
hooks/usePushNotifications.ts dabe5ca, 84eeac9 Push tap routing: warm web/native paths resolve DMs to Direct via navigateRoom / getDirectRoomPath (lines 309 / 387); SW cold-start opens /home/{roomId}/ and after P3c HomeRouteRoomProvider redirects every non-space room to /direct/ (the m.direct predicate was lifted in P3c §6.7). FSI hand-off
hooks/useRoomNavigate.ts dce6be9 Back-stack collapse via replace. Path-based (pathnameRef.current === target ? replace : push, line 49-55) — not tab-ID-keyed, so removing *Tab.tsx rendering does not break back-stack
hooks/useAndroidBackButton.ts, components/BackRouteHandler.tsx dce6be9 Hardware back integration
hooks/useAutoDirectSync.ts + utils/matrix.ts 84eeac9 m.direct sync on join (DM rooms shown correctly for invited users)
pages/auth/* c466848, e6623b2, cf5ee9a, 9b41cbb Bistable layout — don't change body/root background CSS-vars; see bugs.md
MainActivity.java::EdgeToEdge.enable(this) + src/index.css lines 45-65 + styles.xml windowLayoutInDisplayCutoutMode=shortEdges 1edaf60 Android edge-to-edge compensation (white-bar fix). WebView draws under status/nav bars; compensation triad: body { background-color: var(--oq6d070) } (currently folds color.Background.Containerbut --oq6d070 is folds@2.6.2 internal implementation detail, not public API; can break on folds upgrade), #root { padding: env(safe-area-inset-*) }, and the manifest cutout mode. Break any one and white bars return or status text overlaps content. DM redesign P0 replaces with Vojo-owned --vojo-safe-area-bg variable + matching WindowCompat.setAppearanceLight{Status,Navigation}Bars(false) in MainActivity.onCreate to keep system-bar icons visible across uiMode. See dm_1x1_redesign.md §6.6 / R13 (safe-area var) and R19 (Android icon tint) for full risk matrix.
scripts/gen-push-strings.mjs + android/app/build.gradle 19d3dc0, 7af04a4 Gradle task generates push_strings.xml from i18n Push.json. Don't put non-push i18n keys in the Push namespace or they leak into lockscreen XML. EN+RU must be added together (web SW falls back to EN if RU missing)
android/app/src/main/java/** calls phases 5.35 FGS, FCM, ring registry, declined-IDs tracker

State Management

Jotai atoms in src/app/state/:

  • 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.
  • sessions.ts — Active session
  • upload.ts — Upload progress (in-memory)
  • room/roomInputDrafts (in-memory), roomToParents, roomToUnread
  • room-list/roomList, mDirectAtom, inviteList, sorting/filtering
  • callEmbed.tscallEmbedAtom, callChatAtom (call lifecycle)
  • closedNavCategories.tsclosedNavCategoriesAtom (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/.

Theming

Stock Cinny had multiple themes; vojo simplified to System / Light / Dark (commit 00935ae).

  • src/colors.css.ts defines only darkTheme via createTheme(color, darkThemeData). There is no separate light-theme override — light = stock folds.lightTheme imported as-is.
  • src/app/hooks/useTheme.ts selects LightTheme or DarkTheme based on useSystemTheme + themeId settings. Class-name-based application — runtime theme switch requires page reload (vanilla-extract is compile-time).
  • Folds tokens (color.*, config.space, config.radii, config.borderWidth) are read-only inside folds compiled CSS. Re-skinning colours via createTheme() works; re-skinning radii or spacing requires CSS overrides outside folds.
  • Brand accent in v4.11.x: Primary.Main = #BDB6EC (lavender) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe.

Responsive design

No CSS media queries for layout. Responsive behaviour goes through hooks/useScreenSize.ts:

ScreenSize.Mobile  // ≤750px
ScreenSize.Tablet  // >750 ≤1124px
ScreenSize.Desktop // >1124px

useScreenSizeContext() returns the current size; components branch their JSX on it.

pages/MobileFriendly.tsx provides two wrappers:

  • MobileFriendlyClientNav — hides global SidebarNav on Mobile unless URL matches a top-level route (/home/, /direct/, /{spaceId}/, /explore/, /inbox/)
  • MobileFriendlyPageNav — hides per-tab PageNav (e.g. the Direct chat list) on Mobile unless URL matches the exact root path (e.g. exactly /direct/)

Result: on Mobile, navigating to a room hides both nav levels — only RoomView is visible. Back-button via useRoomNavigate collapses these into the back-stack via history.replace (commit dce6be9).

The only CSS @media queries in the app target (hover: hover) and (pointer: fine) to disable hover-only effects on touch devices.

Matrix SDK Patterns

const mx = useMatrixClient();                               // Get SDK instance
const room = useRoom();                                     // Current room
const isOneOnOne = useIsOneOnOne();                         // From IsOneOnOneProvider context
const stateEvent = useStateEvent(room, StateEvent.Type);    // Room state
const powerLevels = usePowerLevels(room);                   // Permissions

i18n

i18next + react-i18next. Translations in public/locales/{en,ru}/*.json, organised by namespace (Direct, Room, Settings, Push, …).

  • Push namespace is special: scripts/gen-push-strings.mjs (run as a Gradle task, commit 19d3dc0) reads it and emits android/app/src/main/res/values{,-ru}/push_strings.xml for Android lockscreen. Don't put non-push UI keys in Push. UI strings → Direct/Room/User/etc.
  • Web SW push falls back to EN if a key is missing in RU. Always add EN + RU in the same commit.
  • Russian-language quality: see i18n.md for tone, register, verb-vs-noun rules.

Key Libraries

  • React 18.2 + React Router DOM 6
  • matrix-js-sdk 38.2 — Matrix protocol
  • folds 2.6 — UI component library
  • jotai 2.6 — State management
  • vanilla-extract — Type-safe CSS (compile-time → no runtime theme switching without reload)
  • slate 0.123 — Rich text editor
  • @tanstack/react-query 5 — Data fetching
  • @tanstack/react-virtual 3 — Virtual scrolling — used for list panels (Direct.tsx, space lists, etc.). Note: RoomTimeline.tsx does NOT use this; it uses an in-house useVirtualPaginator + IntersectionObserver.
  • i18next 23 + react-i18next 15 — Localisation
  • Capacitor 8.3 — Native Android wrapper
  • @capacitor/browser 8.0 — External link handling in native
  • vite-plugin-pwa — Custom service worker via injectManifest strategy with injectionPoint: undefined (vite.config.js:132-140). No automatic Workbox precache — the SW is hand-written in src/sw.ts, vite-pwa just builds and registers it. There is no precache size limit to worry about; new font assets are loaded on demand via fetch, not bundled into a precache manifest.
  • @fontsource/inter in dependencies. Other @fontsource/* additions must also go in dependencies (not dev) to land in production bundle.

Git

  • Main branch: dev
  • Current vojo work branch: vojo/dev
  • Semantic-release on dev branch
  • CI: GitHub Actions (build, deploy, docker, netlify)
  • Husky pre-commit is currently disablednpm run typecheck and npm run check:eslint do not run automatically. check:eslint is green; typecheck still has ~32 known errors. Use bash docs/known-tech-debt-lint/diff.sh to check your changes don't add new typecheck errors. Re-enable husky once typecheck residual is cleared.
  • 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

  • Web: npm run builddist/scp to ~/vojo/cinny/ on the production VPS (Caddy serves it)
  • Android debug: npm run build:android:debug (composite of npm run build && npm run android:sync && cd android && ./gradlew assembleDebug)
  • Android release / AAB: similar but assembleRelease / bundleRelease
  • VSCode task Deploy to vojo.chat (Ctrl+Shift+D) automates web deploy
  • Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see android.md

Refactor checklist for AI agents

Encoded from P3c retrospective (2026-04-28) — three classes of bug that ate three rounds of code review. Hit this checklist before declaring a refactor done; each item is cheap to verify and would have caught a real BLOCKER if applied earlier in P3c.

1. Plan-trust = trust-but-verify

When the plan says "X is automatic" or "Y migrates correctly," don't believe it without grepping the affected subsystem. Plans encode intentions; the actual code may have load-bearing assumptions the plan didn't audit.

  • For each utterance of «handle Y по новому пути», grep -rn for the OLD path (atom, helper, gate function) and ensure no lingering callsites read it with the OLD semantic.
  • Especially for load-bearing surfaces (calls, push, FSI, edge-to-edge, service worker, native bridges): the plan §7 «не трогаем» list is a hint that the surface contains hidden coupling, not a license to ignore it. If your refactor changes any visibility/classification gate that touches calls or push, walk every call/push hook by hand.

P3c blocker example: plan §6.8 said «DmCallButton после P3c гейтится на useIsOneOnOne()» without auditing useIncomingRtcNotifications / useCallerAutoHangup which still gated on m.direct. Three rounds of review later we caught it. Cost: one wasted round.

2. Systematic consumer audit before renaming/repurposing

Before changing the semantic of a hook, atom, or context-value (not just renaming — semantic shift), run a grep -rn for every consumer and classify each:

grep -rn "useFoo\|fooAtom\|FooContext" src/

For each callsite, answer:

  • (a) Does the new semantic still match this callsite's intent?
  • (b) Does the callsite use the symbol for ITS original semantic, or for a DIFFERENT semantic that happens to overlap with the old name?

P3c examples missed initially:

  • useDirectRooms was used in UserChips::MutualRoomsChip for «split mutual DMs vs mutual rooms» — needed m.direct semantic, not universal- Direct. Mechanical rename broke the split.
  • mDirects.has(room.roomId) in RoomIntro, 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

If you put a room.X()-style call into a Provider's value=, and room is the matrix-js-sdk Room object (mutable, fires events), the value is a static snapshot at first render. Subsequent state changes won't flow through the context until the Provider re-renders for a different reason.

Symptoms: «UI не обновляется когда X меняется», «надо перезайти в комнату чтобы X применился».

Fix pattern: extract Provider into an inner component that subscribes to the relevant matrix-js-sdk event (e.g. RoomStateEvent.Members, RoomEvent.Receipt, MatrixEventEvent.Decrypted) via useState + useEffect. Capture the emitter ref inside the effect for cleanup leak-safety against rare snapshot replacements. Reference impl: hooks/useIsOneOnOneRoom.ts

P3c blocker example: IsOneOnOneProvider value initially computed as room.getInvitedAndJoinedMemberCount() === 2 at provider mount — froze for the entire route. Inviting a 3rd into a 1:1 didn't flip chrome until navigation. Round-2 review caught it.

4. Don't defer mechanical cleanups «to P6» when one-pass is cheap

Each // TODO: rename X left in the diff multiplies into a 5-file rename sweep later. If a prop name diverges from its semantic during refactor, fix in the same commit if it's < 5 callsites. P6 cleanup is hopeful, not guaranteed.