33 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 enabled and runstsc --noEmit+lint-staged(which callseslint --max-warnings 0on staged JS/TS files). Both gates are zero:npm run typecheckandnpm run check:eslintare green (0 errors, 0 warnings). Custom Matrix event-types (AccountDataEvent.Vojo*,PoniesRoomEmotes,m.bridge,m.call.memberetc.) live insrc/types/matrix/sdkAugmentation.d.ts— add new custom types there to keepmx.getAccountData/mx.getStateEventcalls type-safe.
Source Layout
src/
├── index.tsx # Entry point
├── colors.css.ts # Vojo dark + light themes via createTheme(color, …) — both palettes are Vojo-owned, folds defaults are not used
├── config.css.ts # fontWeight overrides
├── client/
│ ├── initMatrix.ts # Matrix SDK init (createClient, startClient, logout)
│ └── secretStorageKeys.js # Crypto callbacks
├── types/matrix/ # Matrix protocol types (room.ts, accountData.ts, common.ts)
└── app/
├── i18n.ts # i18next config
├── pages/
│ ├── App.tsx # Root component (providers, config loader)
│ ├── Router.tsx # createBrowserRouter / createHashRouter, all routes
│ └── MobileFriendly.tsx # MobileFriendlyClientNav + MobileFriendlyPageNav (responsive split)
├── features/ # Feature modules
├── components/ # Shared components
├── hooks/ # ~117 custom hooks
├── state/ # Jotai atoms
├── plugins/ # Content plugins
├── utils/ # Utilities
└── styles/ # Vanilla-extract global styles
Pages & Routing (src/app/pages/)
Router in Router.tsx. Each top-level tab (/direct/, /space/..., /explore/, /inbox/) is wrapped in PageRoot with a nav prop (the tab's PageNav — e.g. Direct) and an <Outlet/> for the active room/sub-route.
- Auth (
auth/login/,auth/register/,auth/reset-password/) — NOTE: bistable layout fragility, 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/— Redirect-only shim after P3c.HomeRouteRoomProviderredirects/home/{roomId}/to/direct/{roomId}/so cold-start push deep links and pre-P3c bookmarks resolve. No Home page or PageNav exists. The/home/_create,/home/_join,/home/_searchroutes redirect to/direct/.direct/— Universal room list (path:/direct/). After P3c contains every joined «orphan» non-space room (1:1 DMs, group DMs, group rooms, bridged chats, anything that used to live in/home/) plus everym.direct-tagged non-space room — implementation-wiseuseOrphanRooms ∪ useDirects, seeuseDirectRooms.tsfor the full union semantics. Non-m.directrooms that are space children stay only in the parent space tab — they are not duplicated in/direct/. Seedm_1x1_redesign.md§6.8.space/— Space view (path:/:spaceIdOrAlias/). Spaces (= future Channels) keep their own tab and child-room route; they render Stream-style timelines too.explore/— Public rooms (path:/explore/)inbox/— Notifications, invites (path:/inbox/)create/— New room/space (path:/create/)sidebar/— Tab components (DirectTab,SpaceTabs,ExploreTab,CreateTab,SearchTab,UnverifiedTab,InboxTab,SettingsTab). The legacyHomeTabwas removed in P3c.WelcomePage.tsx— Empty state (vojo SVG + version). Mobile is intentionally null at the index route ({mobile ? null : <Route index element={<WelcomePage />} />}) — by design, not a bug.SidebarNav.tsx— global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-upsidebar_cleanupplan once the new Direct/Channels surfaces are self-sufficient.SyncStatus.tsx,SpecVersions.tsx— Connection status
Universal Stream routing + DM classification (post-P3c)
/direct/:roomIdOrAlias
→ PageRoot (nav=Direct, outlet=…)
→ DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2)
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
→ RoomTimeline + RoomViewTyping + RoomInput
After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single member-count check, mirroring Element-Web's tier-2 pattern (room.getInvitedAndJoinedMemberCount() === 2):
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in
useRoomAvatar(room, isOneOnOne)), theDmCallButton, and unconditionally hide membership/nick/avatar syslines. - Group rooms (member-count > 2) get the room-style header (no peer fallback), no
DmCallButton, and respect thehideMembershipEvents/hideNickAvatarEventsuser settings for syslines. - Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
useAutoDirectSync (commit 84eeac9) still round-trips m.direct on join — interop only, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads m.direct for UI classification; the mDirectAtom is kept alive for useDirectRooms ordering and other read-only consumers but its truth is no longer load-bearing for the layout.
Use useIsOneOnOne() from hooks/useRoom.ts whenever you need the 1:1 vs group split — it reads the IsOneOnOneProvider context value set per route. Do NOT introduce a new useIsDirect* helper or the four-source m.direct gate that P3c removed (isDirectStreamRoom, useIsDirectStream, IsDirectRoomProvider, useIsDirectRoom). See docs/plans/dm_1x1_redesign.md §6.8 for the full rationale.
Features (src/app/features/)
| Dir | Purpose |
|---|---|
room/ |
Core room view — RoomTimeline.tsx (~1700 LOC after P3c collapse), RoomInput.tsx (~691 LOC), RoomViewHeader.tsx (thin wrapper after P4) → RoomViewHeaderDm.tsx (Dawn header for every room class; 1:1 chrome via avatar fallback + peer-profile-sheet, group chrome via N members line; phone button three-gated per §6.8b; search/pinned/invite/leave moved into the … menu), MembersDrawer (suppressed for 1:1 in Room.tsx), MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
room/message/ |
Message.tsx (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no isStream gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy Compact/Bubble layouts and MessageLayout enum are gone; Modern.tsx survives only as a card-preview layout for pin-menu / message-search / inbox. |
room-nav/ |
RoomNavItem.tsx (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (useCallSession, useCallMembers, useCallStart) |
room-settings/ |
Room-specific settings page |
common-settings/ |
Shared settings: general, members, permissions, emojis-stickers, developer-tools |
space-settings/ |
Space-specific settings |
settings/ |
User settings (general, account, notifications, devices, emojis, about, dev-tools). MessageLayout / messageSpacing / legacyUsernameColor were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. hideMembershipEvents / hideNickAvatarEvents survive — they still gate the group-room syslines. Logout lives here only. |
lobby/ |
Space/room lobby view |
search/ |
Global search |
message-search/ |
In-room message search |
create-chat/ |
DM creation flow (recent commit 58ec12d split it into username + server fields) |
create-room/ |
Room creation |
create-space/ |
Space creation |
add-existing/ |
Join existing rooms |
join-before-navigate/ |
Pre-join navigation logic |
call/ |
Element Call integration. Vojo-DM voice call lifecycle lives here (phases 0..5.35). Don't touch unless redesigning calls. |
call-status/ |
Call state display + IncomingCallStrip (mounted as sibling in Router.tsx:184, not inside RoomViewHeader). |
Virtualization in features/room/
RoomTimeline.tsx does not use @tanstack/react-virtual. It uses useVirtualPaginator + IntersectionObserver for pagination + scroll-anchoring. There is no estimateSize to retune; row heights are measured live. (The tanstack useVirtualizer is used elsewhere — e.g. Direct.tsx:199 for the DM-list column with estimateSize: () => 38.)
Key Components (src/app/components/)
message/— Message rendering. OnlyStream.tsxandModern.tsxlayouts ship after P3c (Compact/Bubbledeleted along withMessageLayoutenum). Every timeline row uses<StreamLayout/>.Modern.tsxsurvives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines.EventContent.tsxis a single-branch sysline renderer (the legacyIsStreamProvidercontext was deleted; rail metadata flows in viarailStart/railEndprops directly).message/MessageStatus.tsx+hooks/useMessageStatus.ts— Kept but no longer rendered in the timeline. P3c retired the WhatsApp-style checkmarks because the Stream-rail dot now encodes the same delivery / read state via colour and opacity.useMessageStatusis still consumed insideuseDotColor.ts, so the file is load-bearing.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/RoomViewHeaderDm.tsx::DmCallButton |
calls phases 0-5.35 | Outgoing DM voice call entry point. Gated on useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room) after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (useIncomingRtcNotifications, useCallerAutoHangup) which still gate ring delivery and caller-side auto-hangup on m.direct, (3) explicit MSC2346 m.bridge exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan. |
features/call/* (CallEmbed, CallView) |
91afffc, e8188d7, fab533e |
Element Call widget; don't unmount/remount the widget root on header redesign — Android FGS is keyed on joined state |
features/call-status/IncomingCallStrip.tsx |
dabe5ca, 91afffc |
Mounted as sibling in Router.tsx:184 (not inside header). Decline / accept / mid-ring backgrounding handled here |
state/callEmbed.ts (callEmbedAtom, callChatAtom) |
call phases | Embed lifecycle |
components/message/MessageStatus.tsx + hooks/useMessageStatus.ts |
0c4cfb9 |
WhatsApp checkmarks |
hooks/usePushNotifications.ts |
dabe5ca, 84eeac9 |
Push tap routing: warm web/native paths resolve DMs to Direct via navigateRoom / getDirectRoomPath (lines 309 / 387); SW cold-start opens /home/{roomId}/ and after P3c HomeRouteRoomProvider redirects every non-space room to /direct/ (the m.direct predicate was lifted in P3c §6.7). FSI hand-off |
hooks/useRoomNavigate.ts |
dce6be9 |
Back-stack collapse via replace. Path-based (pathnameRef.current === target ? replace : push, line 49-55) — not tab-ID-keyed, so removing *Tab.tsx rendering does not break back-stack |
hooks/useAndroidBackButton.ts, components/BackRouteHandler.tsx |
dce6be9 |
Hardware back integration |
hooks/useAutoDirectSync.ts + utils/matrix.ts |
84eeac9 |
m.direct sync on join (DM rooms shown correctly for invited users) |
pages/auth/* |
c466848, e6623b2, cf5ee9a, 9b41cbb |
Bistable layout — don't change body/root background CSS-vars; see bugs.md |
MainActivity.java::EdgeToEdge.enable(this) + src/index.css lines 45-65 + styles.xml windowLayoutInDisplayCutoutMode=shortEdges |
1edaf60 |
Android edge-to-edge compensation (white-bar fix). WebView draws under status/nav bars; compensation triad: body { background-color: var(--oq6d070) } (currently folds color.Background.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 (themeId,useSystemTheme,monochromeMode,hideMembershipEvents,hideNickAvatarEvents, …). Persisted tolocalStorage['settings']. TheMessageLayoutenum +messageSpacing+legacyUsernameColorfields were dropped in P3c; the newdawn-p3c-cleanupmigration ingetSettings()strips the orphan keys from existing users' persisted JSON on first load. The user-facinghour24Clock/dateFormatStringfields were removed too — both now derive from the runtime locale viaIntl.DateTimeFormatinutils/time.ts(24-hour locales →HH:mm+DD/MM/YYYY; AM/PM locales →hh:mm A+MM/DD/YYYY). Thesystem-time-format-cleanupmigration synchronously deletes those keys on first load. Known platform limitation: Android's manual «Use 24-hour format» toggle in Date & Time settings is invisible to JS —Intlreads only CLDR locale conventions. Russian-locale users with AM/PM toggle get 24-hour format on both web and Capacitor; only a native bridge toandroid.text.format.DateFormat.is24HourFormat(context)would respect that toggle.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 bothdarkTheme(Dawn palette) andlightTheme(Vojo light palette) viacreateTheme(color, …). The folds defaultlightThemeis no longer imported — both Vojo themes own their full token table.src/app/hooks/useTheme.tsselectsLightThemeorDarkThemebased onuseSystemTheme+themeIdsettings. Class-name-based application —ThemeManagerswaps the body class onuseActiveThemechange, so runtime switching is live (no reload needed, but vanilla-extract still requires a rebuild to change the token tables themselves).- 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: dark
Primary.Main = #9580ff(Dawn lavender), lightPrimary.Main = #5b6aff(indigo) — referenced in unread-badge, focus-ring, NavLink active state, MessageBase highlight keyframe. - The default theme picker (Settings → General → Appearance) offers System / Light / Dark. The
dawn-redesign-v1one-shot migration instate/settings.tspins existing users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keepuseSystemTheme: trueso they follow the OS preference out of the box.
Known follow-ups for light theme
The web theme switch is wired end-to-end (palette, picker, runtime body-class swap, mxid colours, prism syntax highlighting, --vojo-safe-area-bg, cold-start prefers-color-scheme fallback in src/index.css, dual <meta theme-color> in index.html). Native and PWA chrome are NOT yet bound to the active theme — track these as separate tasks:
- Android system bars —
MainActivity.java::onCreatehardcodescontroller.setAppearanceLight{Status,Navigation}Bars(false). On light theme the icons are white over a light bar → invisible. Fix is a small JS↔Java bridge (custom Capacitor plugin, or@capacitor/status-barfor status-bar tint + custom plugin for nav-bar) driven fromThemeManager'suseEffect. - Android native splash —
android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11andstyles.xml::windowBackgroundare dark. Light users see a dark splash → fade to white. Addvalues-night/variants or read the storedthemeIdfrom a SharedPreferences shim before paint. - Capacitor WebView paint color —
capacitor.config.ts::backgroundColor = '#0d0e11'(mirrored in the builtcapacitor.config.json). Set at WebView init, cannot be re-themed at runtime via JS — needs the splash-fix above to land first. - PWA manifest —
public/manifest.jsontheme_color/background_colorare pinned to dark (#0d0e11). Manifest format does not support media queries, so the choice is one default; we keep dark because the migration also pins existing users to dark. - AuthLayout —
src/app/pages/auth/styles.css.tshardcodes dark backgrounds (#0d0e11etc.) for the bistable auth scaffold (seebugs.mdfor why the auth layout cannot be naively re-skinned). Light-theme users see a dark login/register/reset-password screen. Tied to the auth bistable-layout refactor. - Bot widgets —
BotShell.css.ts,BotWidgetMount.css.ts,BotCard.tsxhardcode#9580ff/#7ab6d9/#0c0c0eaccent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing throughapps/widget-*or a CSS-var bridge from the parent.
The horseshoe void seam reshades via the --vojo-horseshoe-void CSS variable: dark #090909 (deep void against #0d0e11 panel) and light #d6d6e3 (soft lavender-grey against #f2f2f7 panel). See src/app/styles/horseshoe.ts + src/index.css.
Composer card geometry
Load-bearing pixel values for the main chat composer + thread-drawer composer (both wrap RoomInput with the ChatComposer class). The composer is a floating rounded card with 32px corner radius (VOJO_HORSESHOE_RADIUS_PX); all paddings are tuned so the visible glyphs (text, IconButton icons) stay outside the curve clip. Source of truth: src/app/features/room/RoomView.css.ts, src/app/features/room/RoomInput.tsx (action-row padding).
| 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 (folds default — 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 |
| IconButton internal padding | 4px (SVG 24×24 centered) | folds default |
| Empty-state composer height (single-line, no reply) | ~93px | derived |
Don't override the textarea's vertical padding (13px) without also retuning EditorPlaceholderTextVisual.paddingTop in lockstep: folds tuned the pair so Slate's placeholder span and the typed-text caret land on the same y inside the contenteditable content-box. Diverging the two breaks vertical alignment — typed text and the «Send a message…» placeholder appear at different baselines.
Visual alignment goal — text glyph and Plus icon-glyph sit on the same vertical column at 28px from the card edge (mirrored on the right for Send):
text-glyph-x = outer (16) + textarea paddingLeft (12) = 28icon-glyph-x = outer (16) + row paddingLeft (8) + button-internal-pad (4) = 28
Bottom-left curve clearance (Plus IconButton container vs the 32px corner):
button-bottom y = 6 (outer) + 4 (row pad-bot) = 10curve-x at y=10 = 32 − √(32² − 22²) ≈ 8.76pxbutton-left = 16 (outer) + 8 (row pad-left) = 24- clearance ≈ 15.24px — comfortable for the hit-box; the visible glyph clears the curve by ~23px
Top-left curve clearance (placeholder text glyph):
text-glyph-y = 6 (outer) + 13 (textarea pad-top) = 19curve-x at y=19 = 32 − √(32² − 13²) ≈ 2.76pxtext-glyph-x = 28- clearance ≈ 25.24px — very generous; supports multi-line growth
Future compactness levers (if needed without breaking alignment):
- Outer card vertical padding (currently 6px) — drop to 4px saves 4px
- Action-row padding (currently 2/4) — drop to 0/2 saves 4px
- IconButton size (currently 300 / 32px) — already smallest in folds; no further reduction available
Avoid touching textarea or placeholder vertical padding unless you re-tune both in matched pairs and visually verify glyph alignment.
Don't apply these to other composers: the textarea-padding compact override is scoped to .ChatComposer. The message-edit overlay, Editor.preview.tsx, and any future CustomEditor consumer outside the chat composer keep the folds-default padding: 13px 1px (Editor.css.ts:24-42).
If you re-tune any number here, update both the CSS comments in RoomView.css.ts and this table — they're cross-referenced.
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 isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state
const powerLevels = usePowerLevels(room); // Permissions
i18n
i18next + react-i18next. Translations in public/locales/{en,ru}/*.json, organised by namespace (Direct, Room, Settings, Push, …).
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 41.4 — Matrix protocol (exact pin, see
docs/plans/matrix_js_sdk_upgrade.mdfor the M0..M4 bump trail) - folds 2.6 — UI component library
- jotai 2.6 — State management
- vanilla-extract — Type-safe CSS. Tokens are compile-time, but theme switching is live at runtime:
ThemeManagerswaps the body class onuseActiveThemechange and everycolor.*var reshades through the cascade. Adding new tokens still requires a rebuild. - slate 0.123 — Rich text editor
- @tanstack/react-query 5 — Data fetching
- @tanstack/react-virtual 3 — Virtual scrolling — used for list panels (
Direct.tsx, space lists, etc.). Note:RoomTimeline.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 runs
tsc --noEmit+lint-staged(eslint --max-warnings 0) — both must be green to commit.no-explicit-anyandno-non-null-assertionpolicy: kept as'warn'in.eslintrc.cjsbut blocked by--max-warnings 0. When introducing one is unavoidable (matrix-js-sdk boundary, generic helper, third-party callback shape), add an inline// eslint-disable-next-linewith a one-line justification rather than relaxing the rule. - 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
Refactor checklist for AI agents
Encoded from P3c retrospective (2026-04-28) — three classes of bug that ate three rounds of code review. Hit this checklist before declaring a refactor done; each item is cheap to verify and would have caught a real BLOCKER if applied earlier in P3c.
1. Plan-trust = trust-but-verify
When the plan says "X is automatic" or "Y migrates correctly," don't believe it without grepping the affected subsystem. Plans encode intentions; the actual code may have load-bearing assumptions the plan didn't audit.
- For each utterance of «handle Y по новому пути»,
grep -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): the plan §7 «не трогаем» list is a hint that the surface contains hidden coupling, not a license to ignore it. If your refactor changes any visibility/classification gate that touches calls or push, walk every call/push hook by hand.
P3c blocker example: plan §6.8 said «DmCallButton после P3c гейтится на
useIsOneOnOne()» without auditing useIncomingRtcNotifications /
useCallerAutoHangup which still gated on m.direct. Three rounds of
review later we caught it. Cost: one wasted round.
2. Systematic consumer audit before renaming/repurposing
Before changing the semantic of a hook, atom, or context-value (not
just renaming — semantic shift), run a grep -rn for every consumer and
classify each:
grep -rn "useFoo\|fooAtom\|FooContext" src/
For each callsite, answer:
- (a) Does the new semantic still match this callsite's intent?
- (b) Does the callsite use the symbol for ITS original semantic, or for a DIFFERENT semantic that happens to overlap with the old name?
P3c examples missed initially:
useDirectRoomswas used inUserChips::MutualRoomsChipfor «split mutual DMs vs mutual rooms» — needed m.direct semantic, not universal- Direct. Mechanical rename broke the split.mDirects.has(room.roomId)inRoomIntro,RoomSettings,RoomProfile,SpaceSettingsfor peer-avatar fallback — needed member-count semantic consistent withRoomViewHeader. Mechanical preservation of m.direct diverged the chrome.
3. Reactivity audit for context values from mutable objects
If you put a room.X()-style call into a Provider's value=, and room
is the matrix-js-sdk Room object (mutable, fires events), the value is
a static snapshot at first render. Subsequent state changes won't flow
through the context until the Provider re-renders for a different reason.
Symptoms: «UI не обновляется когда X меняется», «надо перезайти в комнату чтобы X применился».
Fix pattern: extract Provider into an inner component that subscribes to
the relevant matrix-js-sdk event (e.g. RoomStateEvent.Members,
RoomEvent.Receipt, MatrixEventEvent.Decrypted) via useState + useEffect. Capture the emitter ref inside the effect for cleanup
leak-safety against rare snapshot replacements. Reference impl:
hooks/useIsOneOnOneRoom.ts
pages/client/direct/RoomProvider.tsx(theResolvedRoomProvidersplit pattern).
P3c blocker example: IsOneOnOneProvider value initially computed as
room.getInvitedAndJoinedMemberCount() === 2 at provider mount — froze for
the entire route. Inviting a 3rd into a 1:1 didn't flip chrome until
navigation. Round-2 review caught it.
4. Don't defer mechanical cleanups «to P6» when one-pass is cheap
Each // TODO: rename X left in the diff multiplies into a 5-file rename
sweep later. If a prop name diverges from its semantic during refactor,
fix in the same commit if it's < 5 callsites. P6 cleanup is hopeful, not
guaranteed.