vojo/docs/ai/architecture.md

50 KiB
Raw Blame History

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

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 — 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 mountedClientLayout 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/:botIdBotExperienceHost.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 BotRoomProviderBotExperienceRoute 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:

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 paneCallView (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 railIncomingCallStrip (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/ Unified switcher (Cmd+K modal Search.tsx via SearchModalRenderer + the StreamHeader's InlineRoomSearch, both on useRoomSearch.ts). Searches local rooms/DMs/spaces (useAsyncSearch) and a "People" section from the homeserver user directory (mx.searchUserDirectory, debounced 300ms / ≥2 chars; exact @user:server falls back to mx.getProfileInfo, then a soft-added raw id). People are deduped against existing DMs (getDMRoomFor); clicking one opens-or-creates a DM via create-chat/useCreateDirect.ts. This is how you reach someone you haven't chatted with — the old "+ new chat" form is gone from the listing headers (see stream-header/). Findability of who appears is the server's lever: Synapse user_directory.search_all_users (federation only surfaces remote users the server already knows).
message-search/ In-room message search (MessageSearch + filters/input/SearchResultGroup).
create-chat/ DM creation. CreateChat.tsx = the explicit username + server form (FALLBACK_SERVER='vojo.chat', dedup via getDMRoomFor), now reached only by the /u/<user> deep link (UserLinkRedirect/direct/create) and the Direct empty-state CTA — NOT from the listing-header Plus (retired; people are found in search/). useCreateDirect.ts = the shared open-or-create-DM hook used by both CreateChat and the search People section.
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.tshooks/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/ (NEWMembersList 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/InlineRoomSearch). Not the room header — don't confuse with RoomViewHeaderDm. The Plus/new-chat action is gone on Direct (people are found in search); the Plus only renders where a tab supplies a primaryAction (Channels' create-channel/community). The curtain has a single form (form-search); its peek geometry is chip-count-awarepeekTravelPx(chipRows) (1 on Direct, 2 on Channels) is threaded into snapTopPx + both gesture hooks so rest position and commit scale stay in lockstep.
  • 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 replacepath-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.tssettingsAtom (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.tsactiveChannelsSpaceAtom (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.tsno 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 barsMainActivity.java::onCreate hardcodes setAppearanceLight{Status,Navigation}Bars(false).
  • Android native splashres/values/colors.xml::splash_bg = #0d0e11 + styles.xml::windowBackground; no values-night/.
  • Capacitor WebView paintcapacitor.config.ts::android.backgroundColor = '#0d0e11'.
  • PWA manifestpublic/manifest.json theme_color/background_color = #0d0e11 (no media-query support).
  • AuthLayoutpages/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 widgetsBotShell.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, RoomInput.tsx, 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.tsEditorTextarea
Textarea horizontal padding 12px left, 12px right RoomView.css.ts:first-child / :last-child rules
Placeholder paddingTop 13px (must match textarea padding) Editor.css.tsEditorPlaceholderTextVisual
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):

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

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/ru.json (loadPath …/public/locales/{{lng}}.jsonno 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-pwainjectManifest strategy with injectionPoint: undefinedno 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 builddist/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.ymlworkflow_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 syncgradlew 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

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.tsxRoomViewHeaderDm is a thin wrapper whose Dm suffix is now historical; the global SidebarNav rail is dead-but-mounted-nowhere awaiting sidebar_cleanup.)