vojo/docs/ai/architecture.md

20 KiB

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 (/home/, /direct/, /space/..., /explore/, /inbox/) is wrapped in PageRoot with a nav prop (the tab's PageNav — e.g. Home, 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/ — Home timeline (path: /home/, root / redirects here)
    • direct/ — DMs (path: /direct/)
    • space/ — Space view (path: /:spaceIdOrAlias/)
    • explore/ — Public rooms (path: /explore/)
    • inbox/ — Notifications, invites (path: /inbox/)
    • create/ — New room/space (path: /create/)
    • sidebar/ — Tab components (HomeTab, DirectTab, SpaceTabs, ExploreTab, CreateTab, SearchTab, UnverifiedTab, InboxTab, SettingsTab)
    • 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, HomeTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab)
    • SyncStatus.tsx, SpecVersions.tsx — Connection status

DM-specific routing path

/direct/:roomIdOrAlias
  → PageRoot (nav=Direct, outlet=…)
    → DirectRouteRoomProvider (sets IsDirectRoomProvider=true, runs useAutoDirectSync)
      → Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
        → RoomTimeline + RoomViewTyping + RoomInput

useAutoDirectSync (commit 84eeac9) round-trips m.direct on join — important: for invited users the room renders first as non-direct, then flips to direct when sync resolves. Any DM-only branching must therefore go through the shared four-source predicate isDirectStreamRoom(mx, room, mDirects) in src/app/utils/room.ts (introduced in DM redesign P3a). The four sources, all synchronous and first-frame-safe, are:

  1. mDirectAtom.has(roomId) — returning user, populated on first paint via IndexedDBStore.startup() await before startClient(). Hydrated by useBindMDirectAtom from a useEffect, so empty for one frame on cold start.
  2. room.getDMInviter() — matrix-js-sdk SDK helper for invite-state with is_direct: true in member content's prev_content.
  3. isDirectInvite(room, myUserId) — reads current member content for is_direct: true, covering the just-joined case before useAutoDirectSync round-trips.
  4. mx.getAccountData('m.direct') — direct SDK fallback for the cold-start one-frame race when the atom is still being hydrated; the SDK has the data synchronously after IndexedDBStore.startup().

Four call sites use this predicate symmetrically: useIsDirectStream(room) (render-side gate in RoomTimeline.tsx), HomeRouteRoomProvider (cold-start push redirect from /home/{DM}/ to /direct/), DirectRouteRoomProvider (validates the destination), useRoomNavigate.navigateRoom (imperative routing). All four agree on the first frame so cold-start push for an invited DM lands on /direct/ with the Stream layout already mounted, no transient JoinBeforeNavigate flash.

For the per-row Stream gate, prefer the isStream prop chain (RoomTimeline computes useIsDirectStream(room) once and threads it through <Message> / <Event> props) over calling the hook per row — this avoids subscribing every row to the mDirectAtom in non-DM rooms. Don't gate on useIsDirectRoom() alone — it misses the invited-user case. Bridged Telegram DMs that lack is_direct: true on invite are deferred to a future plan (separate Telegram tab/namespace). See docs/plans/dm_1x1_redesign.md §6.5 for the full rationale.

Features (src/app/features/)

Dir Purpose
room/ Core room view — RoomTimeline.tsx (~1890 LOC), RoomInput.tsx (~691 LOC), RoomViewHeader.tsx (~587 LOC), MembersDrawer, MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete
room/message/ Message.tsx (~1335 LOC) — selects layout per the isStream prop (DM rooms → <StreamLayout/>) or per the persisted messageLayout setting (non-DM → Compact/Bubble/Modern), renders edit/delete/react menu, mention/hashtag links, reactions viewer. The DM-stream gate is computed once in RoomTimeline.tsx via useIsDirectStream(room) and threaded as a prop so we don't subscribe every row to mDirectAtom.
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). The MessageLayout dropdown was removed in DM redesign P3a — non-DM rooms still honour the persisted enum, but no UI surfaces it. General now hosts a Settings.message_spacing_dm_note description explaining the spacing override in DM rooms. 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. Layout variants live in message/layout/{Modern,Compact,Bubble,Stream}.tsx + layout.css.ts recipes. DM rooms render the Stream layout unconditionally (gated by useIsDirectStream four-source predicate, see DM-specific routing path above). Non-DM rooms select per MessageLayout enum in state/settings.ts (Modern=0, Compact=1, Bubble=2). EventContent.tsx has its own layout switch for membership/state events plus a IsStreamProvider context that flips it to the thin Stream sysline render in DM rooms.
  • message/MessageStatus.tsxVojo-specific: WhatsApp-style delivery checkmarks (commit 0c4cfb9). Pairs with hooks/useMessageStatus.ts (receipt-derivation).
  • 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/RoomViewHeader.tsx::DmCallButton calls phases 0-5.35 Outgoing DM voice call entry point
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 HomeRouteRoomProvider redirects to /direct/ when mDirectAtom has the room. 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 (MessageLayout enum: Modern=0, Compact=1, Bubble=2; themeId, useSystemTheme, monochromeMode, …). Persisted to localStorage['settings']. The MessageLayout enum is canonical for non-DM rooms; DM rooms ignore it entirely and force the Stream layout regardless. The default for new users is MessageLayout.Bubble.
  • 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 isDirect = useIsDirectRoom();                         // From IsDirectRoomProvider 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, Home, 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