18 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-commitis currently commented out. Absolutenpm run typecheckandnpm run check:eslintare already red by known tech debt (~835 typecheck + 39 eslint errors, seedocs/known-tech-debt-lint/). Usebash docs/known-tech-debt-lint/diff.shto verify your changes added no new errors, thennpm run buildfor 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, seebugs.md. Recent fixes:c466848,e6623b2,cf5ee9a,9b41cbb. Don't changebody/#rootbackground CSS-vars — auth uses computed sizes for safe-area painting. - Client (
client/) — main layout after login (wrapped byClientLayout= 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 use the useIsDirectStream(room) hook (introduced in DM redesign P3a) which combines three synchronous sources: mDirectAtom.has(roomId) (returning user — populated on first paint via IndexedDBStore.startup() await before startClient()) + room.getDMInviter() (matrix-js-sdk SDK helper for invite-state with is_direct: true in member content's prev_content) + isDirectInvite(room, myUserId) (src/app/utils/room.ts:67, reads current member content for is_direct: true). 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 (Compact/Bubble/Modern) per messageLayout setting, renders edit/delete/react menu, mention/hashtag links, reactions viewer |
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). General has the MessageLayout dropdown. 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 inmessage/layout/{Modern,Compact,Bubble}.tsx+layout.css.tsrecipes. Selected perMessageLayoutenum instate/settings.ts(Modern=0, Compact=1, Bubble=2).EventContent.tsxhas its own layout switch for membership/state events.message/MessageStatus.tsx— Vojo-specific: WhatsApp-style delivery checkmarks (commit0c4cfb9). Pairs withhooks/useMessageStatus.ts(receipt-derivation).editor/— Slate-based rich text editor. Autocomplete:RoomMentionAutocomplete,UserMentionAutocomplete,EmoticonAutocomplete,CommandAutocomplete.Editor.tsxis the Slate root — preserve.emoji-board/— Emoji pickerimage-pack-view/— Custom emoji pack managementimage-viewer/,Pdf-viewer/— Media viewerssidebar/—Sidebar.tsx(66px wrapper),SidebarItem.tsx,SidebarStack.tsx,SidebarContent.tsx,Sidebar.css.tsuser-profile/— User info, power chips, moderationmember-tile/— Member list itemspower/— Power level UIupload-card/— Upload progress cardsurl-preview/— Link previewspage/— Page layout wrapper (Page,PageRoot,PageNav,PageHeader,PageContent,PageHero)setting-tile/— Settings list item patternsequence-card/,cutout-card/— Card layoutsuia-stages/— User-interactive auth stages (email, captcha, token)room-intro/— Room introduction cardinvite-user-prompt/,join-address-prompt/,leave-room-prompt/— DialogsBackRouteHandler.tsx— Web back-button → back-stack collapse viareplace(commitdce6be9)
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.Container — but --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 (MessageLayoutenum: Modern=0, Compact=1, Bubble=2;themeId,useSystemTheme,monochromeMode, …). Persisted tolocalStorage['settings']. TheMessageLayoutenum is canonical here.sessions.ts— Active sessionupload.ts— Upload progress (in-memory)room/—roomInputDrafts(in-memory),roomToParents,roomToUnreadroom-list/—roomList,mDirectAtom,inviteList, sorting/filteringcallEmbed.ts—callEmbedAtom,callChatAtom(call lifecycle)closedNavCategories.ts—closedNavCategoriesAtom(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.tsdefines onlydarkThemeviacreateTheme(color, darkThemeData). There is no separate light-theme override — light = stockfolds.lightThemeimported as-is.src/app/hooks/useTheme.tsselectsLightThemeorDarkThemebased onuseSystemTheme+themeIdsettings. 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 viacreateTheme()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 globalSidebarNavon Mobile unless URL matches a top-level route (/home/,/direct/,/{spaceId}/,/explore/,/inbox/)MobileFriendlyPageNav— hides per-tabPageNav(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, …).
Pushnamespace is special:scripts/gen-push-strings.mjs(run as a Gradle task, commit19d3dc0) reads it and emitsandroid/app/src/main/res/values{,-ru}/push_strings.xmlfor Android lockscreen. Don't put non-push UI keys inPush. 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.mdfor 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.tsxdoes NOT use this; it uses an in-houseuseVirtualPaginator+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
injectManifeststrategy withinjectionPoint: undefined(vite.config.js:132-140). No automatic Workbox precache — the SW is hand-written insrc/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/interindependencies. Other@fontsource/*additions must also go independencies(not dev) to land in production bundle.
Git
- Main branch:
dev - Current vojo work branch:
vojo/dev - Semantic-release on
devbranch - CI: GitHub Actions (build, deploy, docker, netlify)
- Husky pre-commit is currently disabled —
npm run typecheckandnpm run check:eslintdo not run automatically. Both are already red by known tech debt; usebash docs/known-tech-debt-lint/diff.shto check your changes don't add new errors - Android
versionCodeis monotonic (commit8064760, 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 build→dist/→scpto~/vojo/cinny/on the production VPS (Caddy serves it) - Android debug:
npm run build:android:debug(composite ofnpm 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