383 lines
33 KiB
Markdown
383 lines
33 KiB
Markdown
# Architecture
|
||
|
||
## Quick Start
|
||
|
||
```bash
|
||
npm start # dev server on :8080
|
||
npm run build # production build → dist/
|
||
npm run lint # eslint + prettier
|
||
npm run typecheck # tsc --noEmit
|
||
```
|
||
|
||
Build: **Vite 5.4** with vanilla-extract, WASM, PWA plugins.
|
||
|
||
> **Note:** `.husky/pre-commit` is enabled and runs `tsc --noEmit` + `lint-staged` (which calls `eslint --max-warnings 0` on staged JS/TS files). Both gates are zero: `npm run typecheck` and `npm run check:eslint` are green (0 errors, 0 warnings). Custom Matrix event-types (`AccountDataEvent.Vojo*`, `PoniesRoomEmotes`, `m.bridge`, `m.call.member` etc.) live in [`src/types/matrix/sdkAugmentation.d.ts`](../../src/types/matrix/sdkAugmentation.d.ts) — add new custom types there to keep `mx.getAccountData` / `mx.getStateEvent` calls 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, see `bugs.md`. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. **Don't change `body`/`#root` background CSS-vars** — auth uses computed sizes for safe-area painting.
|
||
- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
|
||
- `home/` — **Redirect-only shim after P3c.** `HomeRouteRoomProvider` redirects `/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/_search` routes 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 every `m.direct`-tagged non-space room — implementation-wise `useOrphanRooms ∪ useDirects`, see [`useDirectRooms.ts`](../../src/app/pages/client/direct/useDirectRooms.ts) for the full union semantics. **Non-`m.direct` rooms that are space children stay only in the parent space tab** — they are not duplicated in `/direct/`. See `dm_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 legacy `HomeTab` was 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-up `sidebar_cleanup` plan 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)`), the `DmCallButton`, and unconditionally hide membership/nick/avatar syslines.
|
||
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user 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. Only `Stream.tsx` and `Modern.tsx` layouts ship after P3c (`Compact`/`Bubble` deleted along with `MessageLayout` enum). Every timeline row uses `<StreamLayout/>`. `Modern.tsx` survives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines. `EventContent.tsx` is a single-branch sysline renderer (the legacy `IsStreamProvider` context was deleted; rail metadata flows in via `railStart` / `railEnd` props 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. `useMessageStatus` is still consumed inside `useDotColor.ts`, so the file is load-bearing.
|
||
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
|
||
- `emoji-board/` — Emoji picker
|
||
- `image-pack-view/` — Custom emoji pack management
|
||
- `image-viewer/`, `Pdf-viewer/` — Media viewers
|
||
- `sidebar/` — `Sidebar.tsx` (66px wrapper), `SidebarItem.tsx`, `SidebarStack.tsx`, `SidebarContent.tsx`, `Sidebar.css.ts`
|
||
- `user-profile/` — User info, power chips, moderation
|
||
- `member-tile/` — Member list items
|
||
- `power/` — Power level UI
|
||
- `upload-card/` — Upload progress cards
|
||
- `url-preview/` — Link previews
|
||
- `page/` — Page layout wrapper (`Page`, `PageRoot`, `PageNav`, `PageHeader`, `PageContent`, `PageHero`)
|
||
- `setting-tile/` — Settings list item pattern
|
||
- `sequence-card/`, `cutout-card/` — Card layouts
|
||
- `uia-stages/` — User-interactive auth stages (email, captcha, token)
|
||
- `room-intro/` — Room introduction card
|
||
- `invite-user-prompt/`, `join-address-prompt/`, `leave-room-prompt/` — Dialogs
|
||
- `BackRouteHandler.tsx` — Web back-button → back-stack collapse via `replace` (commit dce6be9)
|
||
|
||
## Vojo-specific code paths (preserve when redesigning)
|
||
|
||
These are vojo additions on top of stock Cinny — they crossed many recent stabilization commits and are now load-bearing.
|
||
|
||
| Path | Commits | Notes |
|
||
|---|---|---|
|
||
| `features/room/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 to `localStorage['settings']`. The `MessageLayout` enum + `messageSpacing` + `legacyUsernameColor` fields were dropped in P3c; the new `dawn-p3c-cleanup` migration in `getSettings()` strips the orphan keys from existing users' persisted JSON on first load. The user-facing `hour24Clock` / `dateFormatString` fields were removed too — both now derive from the runtime locale via `Intl.DateTimeFormat` in `utils/time.ts` (24-hour locales → `HH:mm` + `DD/MM/YYYY`; AM/PM locales → `hh:mm A` + `MM/DD/YYYY`). The `system-time-format-cleanup` migration 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 — `Intl` reads only CLDR locale conventions. Russian-locale users with AM/PM toggle get 24-hour format on both web and Capacitor; only a native bridge to `android.text.format.DateFormat.is24HourFormat(context)` would respect that toggle.
|
||
- `sessions.ts` — Active session
|
||
- `upload.ts` — Upload progress (in-memory)
|
||
- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
|
||
- `room-list/` — `roomList`, `mDirectAtom`, `inviteList`, sorting/filtering
|
||
- `callEmbed.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.ts` defines both `darkTheme` (Dawn palette) and `lightTheme` (Vojo light palette) via `createTheme(color, …)`. The folds default `lightTheme` is no longer imported — both Vojo themes own their full token table.
|
||
- `src/app/hooks/useTheme.ts` selects `LightTheme` or `DarkTheme` based on `useSystemTheme` + `themeId` settings. Class-name-based application — `ThemeManager` swaps the body class on `useActiveTheme` change, 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 via `createTheme()` works; re-skinning radii or spacing requires CSS overrides outside folds.
|
||
- Brand accent: dark `Primary.Main = #9580ff` (Dawn lavender), light `Primary.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-v1` one-shot migration in `state/settings.ts` pins **existing** users (with a stored settings JSON) to dark on first load post-migration; brand-new users skip the migration and keep `useSystemTheme: true` so 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::onCreate` hardcodes `controller.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-bar` for status-bar tint + custom plugin for nav-bar) driven from `ThemeManager`'s `useEffect`.
|
||
- **Android native splash** — `android/app/src/main/res/values/colors.xml::splash_bg = #0d0e11` and `styles.xml::windowBackground` are dark. Light users see a dark splash → fade to white. Add `values-night/` variants or read the stored `themeId` from a SharedPreferences shim before paint.
|
||
- **Capacitor WebView paint color** — `capacitor.config.ts::backgroundColor = '#0d0e11'` (mirrored in the built `capacitor.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.json` `theme_color`/`background_color` are 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.ts` hardcodes dark backgrounds (`#0d0e11` etc.) for the bistable auth scaffold (see `bugs.md` for 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.tsx` hardcode `#9580ff` / `#7ab6d9` / `#0c0c0e` accent + ink colors. Each bot widget is a separate Preact app so it doesn't share Vojo's folds tokens — needs its own theme passing through `apps/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/RoomView.css.ts), [`src/app/features/room/RoomInput.tsx`](../../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) = 28`
|
||
- `icon-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) = 10`
|
||
- `curve-x at y=10 = 32 − √(32² − 22²) ≈ 8.76px`
|
||
- `button-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) = 19`
|
||
- `curve-x at y=19 = 32 − √(32² − 13²) ≈ 2.76px`
|
||
- `text-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`:
|
||
|
||
```ts
|
||
ScreenSize.Mobile // ≤750px
|
||
ScreenSize.Tablet // >750 ≤1124px
|
||
ScreenSize.Desktop // >1124px
|
||
```
|
||
|
||
`useScreenSizeContext()` returns the current size; components branch their JSX on it.
|
||
|
||
`pages/MobileFriendly.tsx` provides two wrappers:
|
||
|
||
- **`MobileFriendlyClientNav`** — hides global `SidebarNav` on Mobile unless URL matches a top-level route (`/home/`, `/direct/`, `/{spaceId}/`, `/explore/`, `/inbox/`)
|
||
- **`MobileFriendlyPageNav`** — hides per-tab `PageNav` (e.g. the Direct chat list) on Mobile unless URL matches the exact root path (e.g. exactly `/direct/`)
|
||
|
||
Result: on Mobile, navigating to a room hides both nav levels — only RoomView is visible. Back-button via `useRoomNavigate` collapses these into the back-stack via `history.replace` (commit dce6be9).
|
||
|
||
The only CSS `@media` queries in the app target `(hover: hover) and (pointer: fine)` to disable hover-only effects on touch devices.
|
||
|
||
## Matrix SDK Patterns
|
||
|
||
```tsx
|
||
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`, …).
|
||
|
||
- **`Push` namespace is special**: `scripts/gen-push-strings.mjs` (run as a Gradle task, commit 19d3dc0) reads it and emits `android/app/src/main/res/values{,-ru}/push_strings.xml` for Android lockscreen. **Don't put non-push UI keys in `Push`.** UI strings → `Direct`/`Room`/`User`/etc.
|
||
- Web SW push falls back to **EN** if a key is missing in RU. Always add EN + RU in the same commit.
|
||
- Russian-language quality: see `i18n.md` for tone, register, verb-vs-noun rules.
|
||
|
||
## Key Libraries
|
||
|
||
- **React 18.2** + React Router DOM 6
|
||
- **matrix-js-sdk 41.4** — Matrix protocol (exact pin, see `docs/plans/matrix_js_sdk_upgrade.md` for 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: `ThemeManager` swaps the body class on `useActiveTheme` change and every `color.*` 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.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
|
||
- **i18next 23 + react-i18next 15** — Localisation
|
||
- **Capacitor 8.3** — Native Android wrapper
|
||
- **@capacitor/browser 8.0** — External link handling in native
|
||
- **vite-plugin-pwa** — Custom service worker via `injectManifest` strategy with `injectionPoint: undefined` (`vite.config.js:132-140`). **No automatic Workbox precache** — the SW is hand-written in `src/sw.ts`, vite-pwa just builds and registers it. There is no precache size limit to worry about; new font assets are loaded on demand via fetch, not bundled into a precache manifest.
|
||
- **`@fontsource/inter`** in `dependencies`. Other `@fontsource/*` additions must also go in `dependencies` (not dev) to land in production bundle.
|
||
|
||
## Git
|
||
|
||
- Main branch: `dev`
|
||
- Current vojo work branch: `vojo/dev`
|
||
- Semantic-release on `dev` branch
|
||
- CI: GitHub Actions (build, deploy, docker, netlify)
|
||
- **Husky pre-commit runs `tsc --noEmit` + `lint-staged` (`eslint --max-warnings 0`)** — both must be green to commit. `no-explicit-any` and `no-non-null-assertion` policy: kept as `'warn'` in `.eslintrc.cjs` but 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-line` with a one-line justification rather than relaxing the rule.
|
||
- **Android `versionCode` is monotonic** (commit 8064760, derived from commit count). Don't squash or rebase across release boundaries — Play store rejects downgrades
|
||
- **Commit message style** (vojo memory): one sentence ≤25 words; no body; no Co-Authored-By trailer
|
||
|
||
## Build & deploy
|
||
|
||
- Web: `npm run build` → `dist/` → `scp` to `~/vojo/cinny/` on the production VPS (Caddy serves it)
|
||
- Android debug: `npm run build:android:debug` (composite of `npm run build && npm run android:sync && cd android && ./gradlew assembleDebug`)
|
||
- Android release / AAB: similar but `assembleRelease` / `bundleRelease`
|
||
- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy
|
||
- Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see `android.md`
|
||
|
||
## 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 -rn` for the OLD
|
||
path (atom, helper, gate function) and ensure no lingering callsites read
|
||
it with the OLD semantic.
|
||
- Especially for **load-bearing surfaces** (calls, push, FSI, edge-to-edge,
|
||
service worker, native bridges): 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:
|
||
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split
|
||
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
|
||
Direct. Mechanical rename broke the split.
|
||
- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`,
|
||
`SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||
consistent with `RoomViewHeader`. 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`](../../src/app/hooks/useIsOneOnOneRoom.ts)
|
||
+ [`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx)
|
||
(the `ResolvedRoomProvider` split 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.
|