50 KiB
Architecture
Last actualized 2026-05-30 against the code on
vojo/dev(HEADa84c5341). 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-commitrunstsc --noEmit+lint-staged(which callseslint --max-warnings 0on staged JS/TS files — NOT the fullnpm run lint). Both gates are zero:npm run typecheckandnpm run check:eslintare green. Custom Matrix event-types live insrc/types/matrix/sdkAugmentation.d.ts— it augmentsAccountDataEventswithin.vojo.spaces,io.element.recent_emoji,im.ponies.user_emotes,im.ponies.emote_roomsandStateEventswithim.ponies.room_emotes,in.vojo.room.power_level_tags,m.bridge,uk.half-shot.bridge(all typedunknown; callsites use.getContent<T>()). Add new custom event-type strings there to keepmx.getAccountData/mx.getStateEventtype-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.tsxexports two components:ChannelsRootNav(the/channels/index nav —StreamHeadersegment switcher +ChannelsLandingempty-state, Plus = create space) andChannels(the workspace listing —ChannelsWorkspaceHorseshoe>StreamHeader pinKey="channels">ChannelsList,WorkspaceFooter, Plus = create channel in active space; writesactiveChannelsSpaceAtom).ChannelsLanding.tsxresolves the active space (URL > localStorage viauseActiveSpace> first joined orphan) andNavigate-redirects to/channels/:space/.ChannelPickPlaceholder.tsxis the desktop center-pane stub when no room is selected.WorkspaceSwitcherSheet.tsx/WorkspaceFooter.tsx/SpaceAvatar.tsxbuild the workspace switcher.- Rooms under
/channels/are wrapped inChannelsModeProvider value={true}and rendered throughSpaceRouteRoomProvider(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) rendersStreamHeader pinKey="bots"+BotCardrows fromuseBotPresets(). Catalog-only, no Matrix listeners./bots/:botId→BotExperienceHost.tsx(lazy): resolves the preset, runsuseBotRoom(preset)state machine (none / self-invite / bot-invite / bot-kicked / unsafe-membership / ready) → rendersBotNotConnected / BotInvitePending / BotKicked / BotUnsafeRoom / BotStatePage, or onreadywrapsBotRoomProvider→BotExperienceRoutewhich branches onbotShowChatAtomFamily(roomId): chat →<Room renderRoomView={BotChatFallback}>, else<BotShell>(the widget). Seefeatures/bots/for the host internals.
Mobile responsive nav
MobileFriendlyPageNav(path)(LIVE) — on Mobile renders the per-tab PageNav only whenuseMatch(path, {end:true})matches exactly; otherwise null (navigating into a room hides the list).MobileFriendlyClientNavis dead code (never invoked sincenav={null}).components/mobile-tabs-pager/drives the mobile listing nav:MobileTabsLayout(a no-path layout route) rendersMobileTabsPageronly on mobile + native + a listing-root URL (/direct/,/channels/,/channels/:space/,/bots/); everywhere else it falls through to<Outlet/>.MobileTabsPagermounts Direct + (Channels|ChannelsRootNav) + Bots panes once and slides between them via CSS transform + swipe gesture, navigating withreplace. It's intentionally not exported (onlyMobileTabsLayoutis). Gesture is disabled whilesettingsSheetAtom/channelsWorkspaceSheetAtomare 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. channelsModeadditionally enables channels-only filtering (thread surfacing viaThreadSummaryCard, hiding thread-replies/edits/reactions/RTC from the centre column viaisChannelsModeHidden).- 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 toJoinBeforeNavigate.SpaceRouteRoomProvider(space/RoomProvider.tsx) — used by both legacy/:space/:roomAND/channels/:space/:room; validates parent-child membership.BotRoomProvider— for bot rooms.HomeRouteRoomProvideronly 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; the … overflow opens room-actions/RoomActionsMenu in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat ActionRow vocabulary (RoomActions.tsx) on the dark-blue Vojo composer tone (folds Menu variant="SurfaceVariant" = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). 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/ |
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',headerInBubblecompact 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.tsxand theMessageLayoutenum are deleted. Alsolayout.css.ts,Channel.css.ts,streamDebug.ts.message/content/—EventContent.tsxis now a two-branch sysline renderer (layout?: 'stream'|'channel';'channel'→<ChannelEventContent>, else the Stream rail/dot grid). The legacyIsStreamProvidercontext is gone; rail metadata flows viarailStart/railEndprops. PlusImage/Video/Audio/File/Thumbnail/FallbackContent.message/attachment/—Attachment.tsx+ the Stream-media bubble shells (StreamMediaShellaspect-clamped 320px,StreamMediaImage,StreamMediaVideo).message/placeholder/—DefaultPlaceholder,LinePlaceholder.- Top-level:
MessageStatus.tsx(kept, not rendered in timeline — consumed only byhooks/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 viaRoomEvent.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 +MediaControlslayout shell.image-viewer/,Pdf-viewer/,text-viewer/(NEW, lazy Prism),image-editor/(NEW, currently a stub —handleApplyis 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 exportsRoomIcon),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 —MembersListDawn 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 beneathfeatures/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 withRoomViewHeaderDm. The Plus/new-chat action is gone on Direct (people are found in search); the Plus only renders where a tab supplies aprimaryAction(Channels' create-channel/community). The curtain has a single form (form-search); its peek geometry is chip-count-aware —peekTravelPx(chipRows)(1 on Direct, 2 on Channels) is threaded intosnapTopPx+ 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 deadSidebarNav).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, providesCallEmbedContext/CallEmbedRefContext, runsCallUtils+useAndroidCallForegroundSync. Load-bearing — Android FGS is keyed oncallEmbedAtom.- 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 viareplace).
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(keysettings; customgetSettings/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(keyvojo.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 aremake…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, plussettingsSheetAtom(mobile-only Settings) andchannelsWorkspaceSheetAtom. 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 onhideActivity),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); onlygetFallbackSession/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.tsdefinesdarkTheme(Dawn palette) andlightTheme(Vojo light) viacreateTheme(color, …)— both Vojo-owned (folds defaultlightThemenot imported).config.css.tsaddsonDarkFontWeight/onLightFontWeight.hooks/useTheme.ts:DarkTheme.classNames = ['dark-theme', darkTheme, onDarkFontWeight, 'prism-dark'],LightThememirror.useActiveTheme()picks onuseSystemTheme+themeId.pages/ThemeManager.tsxexports two components (no singleThemeManager):UnAuthRouteThemeManager(auth routes — follows OSprefers-color-schemeonly) andAuthRouteThemeManager(authed routes — readsuseActiveTheme(), swaps body class, appliesmonochromeModeasdocument.body.style.filter = 'grayscale(1)', providesThemeContext). Both dobody.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(:rootlight defaults,.dark-themeoverrides):--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-sweepdeclared in rawindex.css(vanilla-extract lacks@property). - Horseshoe seam:
--vojo-horseshoe-voidis#d6d6e3(light) /#000000(dark).styles/horseshoe.tsexportsVOJO_HORSESHOE_VOID_COLOR,VOJO_HORSESHOE_GAP_PX = 12,VOJO_HORSESHOE_RADIUS_PX = 32; consumed byHorseshoeContainer.
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::onCreatehardcodessetAppearanceLight{Status,Navigation}Bars(false). - Android native splash —
res/values/colors.xml::splash_bg = #0d0e11+styles.xml::windowBackground; novalues-night/. - Capacitor WebView paint —
capacitor.config.ts::android.backgroundColor = '#0d0e11'. - PWA manifest —
public/manifest.jsontheme_color/background_color=#0d0e11(no media-query support). - AuthLayout —
pages/auth/styles.css.tshardcodes#0d0e11(tied to the auth bistable-layout refactor; the file also carries its ownmax-width/max-heightlayout media queries). - Bot widgets —
BotShell.css.ts/BotWidgetMount.css.ts/BotCard.tsxhardcode#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.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):
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
@mediaqueries 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.csslight cold-start fallback),(hover: hover) and (pointer: fine)hover-gates (Sidebar/Channel.css), and a fewmax-width/max-heightqueries insideBotShell.css.tsandauth/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}}.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']withcaches:[](tracks system language each launch — no in-app selector).Pushnamespace is special:scripts/gen-push-strings.mjs(Gradle task) reads it and emitsandroid/.../values{,-ru}/push_strings.xmlfor the Android lockscreen. Don't put non-push UI keys inPush. 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 —
injectManifeststrategy withinjectionPoint: undefined→ no Workbox precache; the SW is the hand-writtensrc/sw.ts. @fontsource/inter+@fontsource-variable/jetbrains-monoindependencies(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, seeelectron.md).
Build & deploy
- Web (local):
npm run build→dist/→rsync/scpto~/vojo/cinny/on the VPS (Caddy serves it). VSCode taskDeploy to vojo.chat(Ctrl+Shift+D) automates it.Deploy widgetsbuilds the three Preact bot-widget apps. - Web (CI):
.github/workflows/prod-deploy.yml—workflow_dispatchonly →git describe --tags→ build → Netlify production deploy → GPG-signedtar.gzGitHub 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 fromgit describe --tags --match 'v*'mirrored invite.config.js::resolveAppVersion()andandroid/app/build.gradle. Seeandroid.md. - Electron:
npm run build:electron:win(or:win:dockerfrom WSL). Seeelectron.md. - Build tooling: Node engine
>=22.12.0, but.node-versionpins 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.jsonversionfield (0.2.0) is stale relative to git tags — the runtime version comes fromgit describe.
Git
- Upstream Cinny main branch is
dev; Vojo work branch isvojo/dev(theoriginremote,git.vojo.chat). Localdevtracks stale upstream Cinny. PRs targetmainper the working environment. - No semantic-release — releases are tag-driven via the manual
prod-deploy.ymlworkflow. - Android
versionCodeis monotonic:major*1_000_000 + minor*1_000 + patch, wherepatch= commit count since thev*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-assertionare'warn'but blocked by--max-warnings 0; when unavoidable (matrix-js-sdk boundary, generic helper, third-party callback), add an inline// eslint-disable-next-linewith 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 -rnfor 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
pages/client/direct/RoomProvider.tsx(theResolvedRoomProvidersplit 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.)