From 103d6ad8a19a70c915331983ffe2a99bc922d681 Mon Sep 17 00:00:00 2001 From: heaven Date: Tue, 28 Apr 2026 21:52:31 +0300 Subject: [PATCH] redesign(p3c): collapse Home into universal Direct, drop legacy layouts, gate room flavour on member-count, and clear orphan settings. --- docs/ai/architecture.md | 132 +++++-- public/locales/en.json | 3 - public/locales/ru.json | 3 - .../message/content/EventContent.tsx | 126 ++---- src/app/components/message/layout/Bubble.tsx | 63 --- src/app/components/message/layout/Compact.tsx | 18 - src/app/components/message/layout/Stream.tsx | 19 +- src/app/components/message/layout/index.ts | 2 - .../components/message/layout/layout.css.ts | 41 -- .../placeholder/CompactPlaceholder.tsx | 27 -- .../components/message/placeholder/index.ts | 1 - src/app/components/room-intro/RoomIntro.tsx | 11 +- src/app/components/user-profile/UserChips.tsx | 14 +- src/app/features/call/PrescreenControls.tsx | 6 +- .../common-settings/general/RoomProfile.tsx | 10 +- .../features/message-search/MessageSearch.tsx | 15 +- src/app/features/room-nav/DmStreamRow.tsx | 15 +- .../features/room-settings/RoomSettings.tsx | 9 +- src/app/features/room/RoomInput.tsx | 8 +- src/app/features/room/RoomTimeline.tsx | 290 ++++---------- src/app/features/room/RoomViewHeader.tsx | 34 +- src/app/features/room/message/Message.tsx | 253 ++---------- src/app/features/room/message/styles.css.ts | 19 - .../room/room-pin-menu/RoomPinMenu.tsx | 7 +- src/app/features/settings/general/General.tsx | 95 +---- .../features/space-settings/SpaceSettings.tsx | 8 +- src/app/hooks/router/useHomeSelected.ts | 47 --- src/app/hooks/useIsDirectStream.ts | 21 - src/app/hooks/useIsOneOnOneRoom.ts | 34 ++ src/app/hooks/useMessageSpacing.ts | 38 -- src/app/hooks/useRoom.ts | 15 +- src/app/hooks/useRoomNavigate.ts | 30 +- src/app/pages/Router.tsx | 30 +- src/app/pages/client/SidebarNav.tsx | 2 - src/app/pages/client/direct/RoomProvider.tsx | 38 +- src/app/pages/client/direct/useDirectRooms.ts | 46 ++- src/app/pages/client/home/CreateRoom.tsx | 58 --- src/app/pages/client/home/Home.tsx | 367 ------------------ src/app/pages/client/home/RoomProvider.tsx | 51 ++- src/app/pages/client/home/Search.tsx | 56 --- src/app/pages/client/home/index.ts | 2 - src/app/pages/client/home/useHomeRooms.ts | 14 - src/app/pages/client/inbox/Notifications.tsx | 6 +- src/app/pages/client/sidebar/DirectTab.tsx | 11 +- src/app/pages/client/sidebar/HomeTab.tsx | 150 ------- src/app/pages/client/sidebar/index.ts | 1 - src/app/pages/client/space/RoomProvider.tsx | 34 +- src/app/pages/client/space/Search.tsx | 13 +- src/app/state/hooks/roomList.ts | 31 ++ src/app/state/settings.ts | 29 +- src/app/utils/room.ts | 52 +-- src/types/matrix/room.ts | 9 + 52 files changed, 641 insertions(+), 1773 deletions(-) delete mode 100644 src/app/components/message/layout/Bubble.tsx delete mode 100644 src/app/components/message/layout/Compact.tsx delete mode 100644 src/app/components/message/placeholder/CompactPlaceholder.tsx delete mode 100644 src/app/hooks/router/useHomeSelected.ts delete mode 100644 src/app/hooks/useIsDirectStream.ts create mode 100644 src/app/hooks/useIsOneOnOneRoom.ts delete mode 100644 src/app/hooks/useMessageSpacing.ts delete mode 100644 src/app/pages/client/home/CreateRoom.tsx delete mode 100644 src/app/pages/client/home/Home.tsx delete mode 100644 src/app/pages/client/home/Search.tsx delete mode 100644 src/app/pages/client/home/useHomeRooms.ts delete mode 100644 src/app/pages/client/sidebar/HomeTab.tsx diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index cf9afca5..506ba75b 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -41,53 +41,52 @@ src/ ## 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 `` for the active room/sub-route. +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 `` 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/` — Home timeline (path: `/home/`, root `/` redirects here) - - `direct/` — DMs (path: `/direct/`) - - `space/` — Space view (path: `/:spaceIdOrAlias/`) + - `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 (`HomeTab`, `DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`) + - `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 : } />}`) — by design, not a bug. - - `SidebarNav.tsx` — global 66px icon-rail (DirectTab, HomeTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab) + - `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 -### DM-specific routing path +### Universal Stream routing + DM classification (post-P3c) ``` /direct/:roomIdOrAlias → PageRoot (nav=Direct, outlet=…) - → DirectRouteRoomProvider (sets IsDirectRoomProvider=true, runs useAutoDirectSync) + → DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2) → 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 go through the **shared four-source predicate `isDirectStreamRoom(mx, room, mDirects)` in [src/app/utils/room.ts](../../src/app/utils/room.ts)** (introduced in DM redesign P3a). The four sources, all synchronous and first-frame-safe, are: +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. `mDirectAtom.has(roomId)` — returning user, populated on first paint via `IndexedDBStore.startup()` await before `startClient()`. Hydrated by `useBindMDirectAtom` from a `useEffect`, so empty for one frame on cold start. -2. `room.getDMInviter()` — matrix-js-sdk SDK helper for invite-state with `is_direct: true` in member content's `prev_content`. -3. `isDirectInvite(room, myUserId)` — reads current member content for `is_direct: true`, covering the just-joined case before `useAutoDirectSync` round-trips. -4. `mx.getAccountData('m.direct')` — direct SDK fallback for the cold-start one-frame race when the atom is still being hydrated; the SDK has the data synchronously after `IndexedDBStore.startup()`. +- 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. -Four call sites use this predicate symmetrically: `useIsDirectStream(room)` (render-side gate in `RoomTimeline.tsx`), `HomeRouteRoomProvider` (cold-start push redirect from `/home/{DM}/` to `/direct/`), `DirectRouteRoomProvider` (validates the destination), `useRoomNavigate.navigateRoom` (imperative routing). All four agree on the first frame so cold-start push for an invited DM lands on `/direct/` with the Stream layout already mounted, no transient `JoinBeforeNavigate` flash. +`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. -For the per-row Stream gate, prefer the `isStream` prop chain (RoomTimeline computes `useIsDirectStream(room)` once and threads it through `` / `` props) over calling the hook per row — this avoids subscribing every row to the `mDirectAtom` in non-DM rooms. 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. +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** (~1890 LOC), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (~587 LOC), MembersDrawer, MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete | -| `room/message/` | `Message.tsx` (~1335 LOC) — selects layout per the `isStream` prop (DM rooms → ``) or per the persisted `messageLayout` setting (non-DM → Compact/Bubble/Modern), renders edit/delete/react menu, mention/hashtag links, reactions viewer. The DM-stream gate is computed once in `RoomTimeline.tsx` via `useIsDirectStream(room)` and threaded as a prop so we don't subscribe every row to `mDirectAtom`. | +| `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (~587 LOC), MembersDrawer, 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). The `MessageLayout` dropdown was removed in DM redesign P3a — non-DM rooms still honour the persisted enum, but no UI surfaces it. General now hosts a `Settings.message_spacing_dm_note` description explaining the spacing override in DM rooms. **Logout lives here only.** | +| `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 | @@ -105,8 +104,8 @@ For the per-row Stream gate, prefer the `isStream` prop chain (RoomTimeline comp ## Key Components (`src/app/components/`) -- `message/` — Message rendering. Layout variants live in `message/layout/{Modern,Compact,Bubble,Stream}.tsx` + `layout.css.ts` recipes. **DM rooms** render the Stream layout unconditionally (gated by `useIsDirectStream` four-source predicate, see DM-specific routing path above). **Non-DM rooms** select per `MessageLayout` enum in `state/settings.ts` (Modern=0, Compact=1, Bubble=2). `EventContent.tsx` has its own layout switch for membership/state events plus a `IsStreamProvider` context that flips it to the thin Stream sysline render in DM rooms. -- `message/MessageStatus.tsx` — **Vojo-specific**: WhatsApp-style delivery checkmarks (commit 0c4cfb9). Pairs with `hooks/useMessageStatus.ts` (receipt-derivation). +- `message/` — Message rendering. Only `Stream.tsx` and `Modern.tsx` layouts ship after P3c (`Compact`/`Bubble` deleted along with `MessageLayout` enum). Every timeline row uses ``. `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 @@ -131,12 +130,12 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab | Path | Commits | Notes | |---|---|---| -| `features/room/RoomViewHeader.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point | +| `features/room/RoomViewHeader.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 `HomeRouteRoomProvider` redirects to `/direct/` when `mDirectAtom` has the room. FSI hand-off | +| `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) | @@ -149,7 +148,7 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab **Jotai** atoms in `src/app/state/`: -- `settings.ts` — User preferences (`MessageLayout` enum: Modern=0, Compact=1, Bubble=2; `themeId`, `useSystemTheme`, `monochromeMode`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum is canonical for non-DM rooms; **DM rooms ignore it entirely** and force the Stream layout regardless. The default for new users is `MessageLayout.Bubble`. +- `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. - `sessions.ts` — Active session - `upload.ts` — Upload progress (in-memory) - `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread` @@ -194,7 +193,7 @@ The only CSS `@media` queries in the app target `(hover: hover) and (pointer: fi ```tsx const mx = useMatrixClient(); // Get SDK instance const room = useRoom(); // Current room -const isDirect = useIsDirectRoom(); // From IsDirectRoomProvider context +const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context const stateEvent = useStateEvent(room, StateEvent.Type); // Room state const powerLevels = usePowerLevels(room); // Permissions ``` @@ -216,7 +215,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga - **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.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`. +- **@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 @@ -240,3 +239,86 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga - 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. diff --git a/public/locales/en.json b/public/locales/en.json index cffe521f..95c1edaa 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -128,9 +128,6 @@ "hide_activity": "Hide Typing & Read Receipts", "hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.", "messages": "Messages", - "message_spacing": "Message Spacing", - "message_spacing_dm_note": "Direct messages always use a fixed spacing so the timeline rail stays continuous.", - "legacy_username_color": "Legacy Username Color", "hide_membership": "Hide Membership Change", "hide_profile": "Hide Profile Change", "disable_media_auto_load": "Disable Media Auto Load", diff --git a/public/locales/ru.json b/public/locales/ru.json index 83500a6d..8fd83a5d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -128,9 +128,6 @@ "hide_activity": "Скрыть набор текста и уведомления о прочтении", "hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.", "messages": "Сообщения", - "message_spacing": "Интервал сообщений", - "message_spacing_dm_note": "В личных чатах используется фиксированный интервал, чтобы линия таймлайна оставалась непрерывной.", - "legacy_username_color": "Классический цвет имени", "hide_membership": "Скрыть изменения участников", "hide_profile": "Скрыть изменения профиля", "disable_media_auto_load": "Отключить автозагрузку медиа", diff --git a/src/app/components/message/content/EventContent.tsx b/src/app/components/message/content/EventContent.tsx index fcbc4acf..4a129aeb 100644 --- a/src/app/components/message/content/EventContent.tsx +++ b/src/app/components/message/content/EventContent.tsx @@ -1,34 +1,18 @@ import { Box, Icon, IconSrc, color } from 'folds'; -import React, { ReactNode, createContext, useContext, useRef } from 'react'; +import React, { ReactNode, useRef } from 'react'; import classNames from 'classnames'; -import { BubbleLayout, CompactLayout, ModernLayout } from '..'; -import { MessageLayout } from '../../../state/settings'; import * as layoutCss from '../layout/layout.css'; import { useStreamLayoutDebug } from '../layout/streamDebug'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -// IsStreamProvider lets the Event wrapper (in features/room/message/Message.tsx) -// declare «this state event lives inside a Stream-layout DM». EventContent -// reads it and switches to a sysline render — a thin one-line system row, not -// a bubble. Plumbed through context so EventContent's API doesn't grow a -// `room` prop (it doesn't need the room itself, only the gate decision). -type StreamContext = { - enabled: boolean; - railStart?: boolean; - railEnd?: boolean; -}; - -const IsStreamContext = createContext({ enabled: false }); -export const IsStreamProvider = IsStreamContext.Provider; - export type EventContentProps = { - messageLayout: MessageLayout; time: ReactNode; iconSrc: IconSrc; content: ReactNode; + railStart?: boolean; + railEnd?: boolean; }; -export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) { - const { enabled: isStream, railStart, railEnd } = useContext(IsStreamContext); +export function EventContent({ time, iconSrc, content, railStart, railEnd }: EventContentProps) { const compact = useScreenSizeContext() === ScreenSize.Mobile; const rootRef = useRef(null); const timeRef = useRef(null); @@ -45,81 +29,45 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon dot: dotRef, content: bodyRef, }, - isStream + true ); - if (isStream) { - // Sysline = thin one-line state-event row that lives ON the rail. RoomTimeline - // passes compact time in Stream mode so the 64px rail column stays stable. - return ( + // Sysline = thin one-line state-event row that lives ON the rail. + return ( +
-
- {time} -
- - - - - - -
{content}
-
- {/* `messageLayout` is intentionally unused in stream-mode. */} + {time}
- ); - } - - const beforeJSX = ( - - {messageLayout === MessageLayout.Compact && time} - + - + + + + +
{content}
-
+
); - - const msgContentJSX = ( - - {content} - {messageLayout !== MessageLayout.Compact && time} - - ); - - if (messageLayout === MessageLayout.Compact) { - return {msgContentJSX}; - } - if (messageLayout === MessageLayout.Bubble) { - return ( - - {msgContentJSX} - - ); - } - return {msgContentJSX}; } diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx deleted file mode 100644 index 93ff84bc..00000000 --- a/src/app/components/message/layout/Bubble.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, { ReactNode } from 'react'; -import classNames from 'classnames'; -import { Box, ContainerColor, as, color } from 'folds'; -import * as css from './layout.css'; - -type BubbleArrowProps = { - variant: ContainerColor; -}; -function BubbleLeftArrow({ variant }: BubbleArrowProps) { - return ( - - - - ); -} - -type BubbleLayoutProps = { - hideBubble?: boolean; - before?: ReactNode; - header?: ReactNode; -}; - -export const BubbleLayout = as<'div', BubbleLayoutProps>( - ({ hideBubble, before, header, children, ...props }, ref) => ( - - - {before} - - - {header} - {hideBubble ? ( - children - ) : ( - - - {before ? : null} - {children} - - - )} - - - ) -); diff --git a/src/app/components/message/layout/Compact.tsx b/src/app/components/message/layout/Compact.tsx deleted file mode 100644 index a033919f..00000000 --- a/src/app/components/message/layout/Compact.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React, { ReactNode } from 'react'; -import { Box, as } from 'folds'; -import * as css from './layout.css'; - -type CompactLayoutProps = { - before?: ReactNode; -}; - -export const CompactLayout = as<'div', CompactLayoutProps>( - ({ before, children, ...props }, ref) => ( - - - {before} - - {children} - - ) -); diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx index bf99a278..fc407fd9 100644 --- a/src/app/components/message/layout/Stream.tsx +++ b/src/app/components/message/layout/Stream.tsx @@ -3,16 +3,13 @@ import classNames from 'classnames'; import { as } from 'folds'; import * as css from './layout.css'; import { useStreamLayoutDebug } from './streamDebug'; -import type { MessageSpacing } from '../../../state/settings'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -// Stream rows ignore the persisted `messageSpacing` setting and use a fixed -// gap so the rail-bridge offsets in layout.css.ts (StreamRailBridgeY = S400) -// always match the gap between rows. Settings.message_spacing_dm_note -// surfaces this in the UI. Keep all three Stream call sites -// (RoomTimeline.tsx StreamDayDivider wrapper, Message.tsx Message MessageBase, -// Message.tsx Event MessageBase) on this single constant. -export const STREAM_MESSAGE_SPACING: MessageSpacing = '400'; +// Stream rows use a fixed `S400` gap so the rail-bridge offsets in +// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows. +// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus +// Message.tsx Message / Event MessageBase — share this single constant. +export const STREAM_MESSAGE_SPACING = '400' as const; // Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b). // @@ -108,10 +105,8 @@ export const StreamLayout = as<'div', StreamLayoutProps>( useImperativeHandle(ref, () => rootRef.current as HTMLDivElement); // Debug helper is dev-only and behind a localStorage opt-in (see - // streamDebug.ts). StreamLayout is only ever mounted when the parent - // already decided this row is in a DM stream, so `active` is implicitly - // true here — pass it explicitly to mirror the sysline call site - // (EventContent.tsx) which threads `isStream` through. + // streamDebug.ts). After P3c every timeline row goes through StreamLayout, + // so `active` is unconditionally true here. useStreamLayoutDebug( 'message', { diff --git a/src/app/components/message/layout/index.ts b/src/app/components/message/layout/index.ts index 8d394089..cb968f92 100644 --- a/src/app/components/message/layout/index.ts +++ b/src/app/components/message/layout/index.ts @@ -1,5 +1,3 @@ export * from './Modern'; -export * from './Compact'; -export * from './Bubble'; export * from './Stream'; export * from './Base'; diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index a6a7d05c..e5b47b44 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -2,11 +2,6 @@ import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanill import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; -export const StickySection = style({ - position: 'sticky', - top: config.space.S100, -}); - const SpacingVar = createVar(); const SpacingVariant = styleVariants({ '0': { @@ -108,15 +103,6 @@ export const MessageBase = recipe({ export type MessageBaseVariants = RecipeVariants; -export const CompactHeader = style([ - DefaultReset, - StickySection, - { - maxWidth: toRem(170), - width: '100%', - }, -]); - export const AvatarBase = style({ paddingTop: toRem(4), transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)', @@ -134,33 +120,6 @@ export const ModernBefore = style({ minWidth: toRem(36), }); -export const BubbleBefore = style({ - minWidth: toRem(36), -}); - -export const BubbleContent = style({ - maxWidth: toRem(800), - padding: config.space.S200, - backgroundColor: color.SurfaceVariant.Container, - color: color.SurfaceVariant.OnContainer, - borderRadius: config.radii.R500, - position: 'relative', -}); - -export const BubbleContentArrowLeft = style({ - borderTopLeftRadius: 0, -}); - -export const BubbleLeftArrow = style({ - width: toRem(9), - height: toRem(8), - - position: 'absolute', - top: 0, - left: toRem(-8), - zIndex: 1, -}); - export const Username = style({ overflow: 'hidden', whiteSpace: 'nowrap', diff --git a/src/app/components/message/placeholder/CompactPlaceholder.tsx b/src/app/components/message/placeholder/CompactPlaceholder.tsx deleted file mode 100644 index e6168ae3..00000000 --- a/src/app/components/message/placeholder/CompactPlaceholder.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React, { useMemo } from 'react'; -import { as, ContainerColor, toRem } from 'folds'; -import { randomNumberBetween } from '../../../utils/common'; -import { LinePlaceholder } from './LinePlaceholder'; -import { CompactLayout } from '../layout'; - -export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>( - ({ variant, ...props }, ref) => { - const nameSize = useMemo(() => randomNumberBetween(40, 100), []); - const msgSize = useMemo(() => randomNumberBetween(120, 500), []); - - return ( - - - - - } - > - - - ); - } -); diff --git a/src/app/components/message/placeholder/index.ts b/src/app/components/message/placeholder/index.ts index 8a3a222f..7858c9ca 100644 --- a/src/app/components/message/placeholder/index.ts +++ b/src/app/components/message/placeholder/index.ts @@ -1,3 +1,2 @@ export * from './LinePlaceholder'; -export * from './CompactPlaceholder'; export * from './DefaultPlaceholder'; diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index 73909b45..2381a75d 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useState } from 'react'; import { Avatar, Box, Button, Spinner, Text, as } from 'folds'; import { Room } from 'matrix-js-sdk'; -import { useAtomValue } from 'jotai'; import { Trans, useTranslation } from 'react-i18next'; import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room'; import { getMemberDisplayName, getStateEvent } from '../../utils/room'; @@ -13,7 +12,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { RoomAvatar } from '../room-avatar'; import { nameInitials } from '../../utils/common'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; -import { mDirectAtom } from '../../state/mDirectList'; +import { useIsOneOnOne } from '../../hooks/useRoom'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -28,11 +27,15 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { navigateRoom } = useRoomNavigate(); - const mDirects = useAtomValue(mDirectAtom); + // Match RoomViewHeader's peer-avatar logic — pull the fallback only when the + // room is strictly 1:1, not when it carries an `m.direct` flag. Bridged + // Telegram 1:1s and just-promoted self-DMs both lack the flag but have + // member-count = 2, so this picks them up correctly. + const isOneOnOne = useIsOneOnOne(); const [invitePrompt, setInvitePrompt] = useState(false); const createEvent = getStateEvent(room, StateEvent.RoomCreate); - const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId)); + const avatarMxc = useRoomAvatar(room, isOneOnOne); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined; diff --git a/src/app/components/user-profile/UserChips.tsx b/src/app/components/user-profile/UserChips.tsx index 8621b3ba..581df34f 100644 --- a/src/app/components/user-profile/UserChips.tsx +++ b/src/app/components/user-profile/UserChips.tsx @@ -1,4 +1,5 @@ import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; @@ -30,7 +31,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; -import { useDirectRooms } from '../../pages/client/direct/useDirectRooms'; +import { mDirectAtom } from '../../state/mDirectList'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { openExternalUrl } from '../../utils/capacitor'; import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; @@ -237,7 +238,10 @@ export function MutualRoomsChip({ userId }: { userId: string }) { const mutualRoomsState = useMutualRooms(userId); const { navigateRoom, navigateSpace } = useRoomNavigate(); const closeUserRoomProfile = useCloseUserRoomProfile(); - const directs = useDirectRooms(); + // Read m.direct directly here, NOT useDirectRooms — after P3c the latter + // returns ALL non-space rooms (universal Direct list), which would collapse + // the «mutual DMs» vs «mutual rooms» split in this profile sheet. + const mDirects = useAtomValue(mDirectAtom); const useAuthentication = useMediaAuthentication(); const allJoinedRooms = useAllJoinedRoomsSet(); @@ -268,7 +272,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) { data.spaces.push(room); return; } - if (directs.includes(room.roomId)) { + if (mDirects.has(room.roomId)) { data.directs.push(room); return; } @@ -276,7 +280,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) { }); } return data; - }, [mutualRoomsState, getRoom, directs, mx]); + }, [mutualRoomsState, getRoom, mDirects, mx]); if ( userId === mx.getSafeUserId() || @@ -288,7 +292,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) { const renderItem = (room: Room) => { const { roomId } = room; - const dm = directs.includes(roomId); + const dm = mDirects.has(roomId); return ( diff --git a/src/app/features/room-nav/DmStreamRow.tsx b/src/app/features/room-nav/DmStreamRow.tsx index 2faea0b5..f02f18dd 100644 --- a/src/app/features/room-nav/DmStreamRow.tsx +++ b/src/app/features/room-nav/DmStreamRow.tsx @@ -25,7 +25,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar } from '../../components/room-avatar'; -import { getDirectRoomAvatarUrl } from '../../utils/room'; +import { getDirectRoomAvatarUrl, getRoomAvatarUrl, isOneOnOneRoom } from '../../utils/room'; import { nameInitials } from '../../utils/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomUnread } from '../../state/hooks/unread'; @@ -367,7 +367,18 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt ( diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index d210a621..497081b6 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useState } from 'react'; -import { useAtomValue } from 'jotai'; import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds'; import { useTranslation } from 'react-i18next'; import { JoinRule } from 'matrix-js-sdk'; @@ -9,7 +8,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta'; -import { mDirectAtom } from '../../state/mDirectList'; +import { isOneOnOneRoom } from '../../utils/room'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { General } from './general'; import { Members } from '../common-settings/members'; @@ -67,9 +66,11 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { const room = useRoom(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const mDirects = useAtomValue(mDirectAtom); - const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId)); + // Peer-avatar fallback follows the member-count gate, mirroring the room + // header (post-P3c). Settings is a globally-mounted modal, so the + // IsOneOnOneProvider context isn't in scope here — call the helper directly. + const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room)); const roomName = useRoomName(room); const joinRuleContent = useRoomJoinRule(room); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 2112bd3c..ccf995b7 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -111,7 +111,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import colorMXID from '../../../util/colorMXID'; -import { useIsDirectRoom } from '../../hooks/useRoom'; +import { useIsOneOnOne } from '../../hooks/useRoom'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useTheme } from '../../hooks/useTheme'; @@ -133,8 +133,7 @@ export const RoomInput = forwardRef( const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); - const direct = useIsDirectRoom(); + const isOneOnOne = useIsOneOnOne(); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); @@ -159,8 +158,7 @@ export const RoomInput = forwardRef( const replyPowerColor = replyPowerTag?.color ? accessibleTagColors.get(replyPowerTag.color) : undefined; - const replyUsernameColor = - legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor; + const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId)); diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 92bf9448..0ddf9ecf 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -31,17 +31,13 @@ import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembershi import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; import { - Badge, Box, Chip, - ContainerColor, Icon, Icons, - Line, Scroll, Text, as, - color, config, toRem, } from 'folds'; @@ -55,7 +51,6 @@ import { useAlive } from '../../hooks/useAlive'; import { editableActiveElement, scrollToBottom } from '../../utils/dom'; import { DefaultPlaceholder, - CompactPlaceholder, Reply, MessageBase, MessageUnsupportedContent, @@ -87,7 +82,7 @@ import { reactionOrEditEvent, } from '../../utils/room'; import { useSetting } from '../../state/hooks/settings'; -import { MessageLayout, settingsAtom } from '../../state/settings'; +import { settingsAtom } from '../../state/settings'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { Reactions, Message, Event, EncryptedContent } from './message'; import { useMemberEventParser } from '../../hooks/useMemberEventParser'; @@ -120,8 +115,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; -import { useIsDirectRoom } from '../../hooks/useRoom'; -import { useIsDirectStream } from '../../hooks/useIsDirectStream'; +import { useIsOneOnOne } from '../../hooks/useRoom'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -144,16 +138,6 @@ const TimelineFloat = as<'div', css.TimelineFloatVariants>( ) ); -const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>( - ({ variant, children, ...props }, ref) => ( - - - {children} - - - ) -); - export const getLiveTimeline = (room: Room): EventTimeline => room.getUnfilteredTimelineSet().getLiveTimeline(); @@ -438,15 +422,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [messageLayout] = useSetting(settingsAtom, 'messageLayout'); - const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing'); - const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); - const direct = useIsDirectRoom(); - // Stream gate — drives the day-divider Stream variant so the rail flows - // through day boundaries without the legacy TimelineDivider gap. Other - // RoomTimeline render-tree decisions (placeholders, RoomIntro, outline- - // attachment) stay on the existing path until P3b. - const isStream = useIsDirectStream(room); + // After P3c every room renders Stream. The DM-vs-group split that drove the + // membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`, + // not the persisted `m.direct` flag — see plan §6.8. + const isOneOnOne = useIsOneOnOne(); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -617,12 +596,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback( (mEvt: MatrixEvent) => { // «Sending while scrolled up jumps the timeline to live» — Telegram / - // WhatsApp / Slack pattern. Introduced for the DM Stream redesign so - // own messages always land in view. Scoped to Stream rooms (DMs) - // intentionally — channels / Spaces keep the legacy «pin scroll on - // history» behaviour until a separate plan tackles them. + // WhatsApp / Slack pattern. After P3c every room renders Stream, so + // the own-message follow-to-bottom guard is universal. const isOwnLiveStreamMessage = - isStream && mEvt.getSender() === mx.getUserId() && !reactionOrEditEvent(mEvt) && (mEvt.getType() === MessageEvent.RoomMessage || @@ -675,7 +651,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideActivity, isStream] + [mx, room, unreadInfo, hideActivity] ) ); @@ -1113,8 +1089,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli data-message-id={mEventId} room={room} mEvent={mEvent} - messageSpacing={messageSpacing} - messageLayout={messageLayout} collapse={collapse} highlight={highlighted} edit={editId === mEventId} @@ -1138,7 +1112,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli onClick={handleOpenReply} getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} + legacyUsernameColor={isOneOnOne} /> ) } @@ -1158,12 +1132,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(senderId)} accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} + legacyUsernameColor={isOneOnOne} hour24Clock={hour24Clock} dateFormatString={dateFormatString} streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} - isStream={isStream} > {mEvent.isRedacted() ? ( @@ -1178,7 +1151,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} - outlineAttachment={messageLayout === MessageLayout.Bubble} + outlineAttachment /> )}
@@ -1218,8 +1191,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli data-message-id={mEventId} room={room} mEvent={mEvent} - messageSpacing={messageSpacing} - messageLayout={messageLayout} collapse={collapse} highlight={highlighted} edit={editId === mEventId} @@ -1243,7 +1214,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli onClick={handleOpenReply} getMemberPowerTag={getMemberPowerTag} accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} + legacyUsernameColor={isOneOnOne} /> ) } @@ -1263,12 +1234,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} + legacyUsernameColor={isOneOnOne} hour24Clock={hour24Clock} dateFormatString={dateFormatString} streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} - isStream={isStream} > {(() => { if (mEvent.isRedacted()) return ; @@ -1308,7 +1278,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli urlPreview={showUrlPreview} htmlReactParserOptions={htmlReactParserOptions} linkifyOpts={linkifyOpts} - outlineAttachment={messageLayout === MessageLayout.Bubble} + outlineAttachment /> ); } @@ -1351,8 +1321,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli data-message-id={mEventId} room={room} mEvent={mEvent} - messageSpacing={messageSpacing} - messageLayout={messageLayout} collapse={collapse} highlight={highlighted} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} @@ -1380,12 +1348,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli showDeveloperTools={showDeveloperTools} memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')} accessibleTagColors={accessiblePowerTagColors} - legacyUsernameColor={legacyUsernameColor || direct} + legacyUsernameColor={isOneOnOne} hour24Clock={hour24Clock} dateFormatString={dateFormatString} streamRailStart={streamRailStart} streamRailEnd={streamRailEnd} - isStream={isStream} > {mEvent.isRedacted() ? ( @@ -1414,6 +1381,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli streamRailStart, streamRailEnd ) => { + // 1:1 rooms always hide membership/nick/avatar syslines — they are + // pure noise in DMs. Group rooms (3+) respect the per-user settings. + if (isOneOnOne) return null; const membershipChanged = isMembershipChanged(mEvent); if (membershipChanged && hideMembershipEvents) return null; if (!membershipChanged && hideNickAvatarEvents) return null; @@ -1421,12 +1391,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const highlighted = focusItem?.index === item && focusItem.highlight; const parsed = parseMemberEvent(mEvent); const iconSrc = - isStream && parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon; + parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon; const timeJSX = (