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 = (
@@ -1440,17 +1410,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1479,7 +1446,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1493,17 +1460,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1533,7 +1497,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1547,17 +1511,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1587,7 +1548,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1601,17 +1562,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1649,7 +1607,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1663,17 +1621,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1700,7 +1655,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1714,17 +1669,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1753,7 +1705,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
@@ -1767,17 +1719,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
room={room}
mEvent={mEvent}
highlight={highlighted}
- messageSpacing={messageSpacing}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
- streamRailStart={streamRailStart}
- streamRailEnd={streamRailEnd}
- isStream={isStream}
>
@@ -1826,6 +1775,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
if (eventType === StateEvent.RoomMember) {
+ // Mirror the membership-sysline gate from the renderer above so the
+ // rail-endpoint scan and the actual render agree on visibility.
+ if (isOneOnOne) return false;
const membershipChanged = isMembershipChanged(event);
if (membershipChanged && hideMembershipEvents) return false;
if (!membershipChanged && hideNickAvatarEvents) return false;
@@ -1879,7 +1831,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const { before: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => {
const before = new Map();
const after = new Map();
- if (!isStream) return { before, after };
const items = getItems();
const renderableFlags = items.map((item) => {
@@ -1944,15 +1895,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
// back-pagination is possible — otherwise the «origin» dot would be a
// lie about an earlier untouched history.
const streamRailStart =
- isStream &&
- rangeAtStart &&
- !canPaginateBack &&
- streamRenderableItemHasBefore.get(item) === false;
+ rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
const streamRailEnd =
- isStream &&
- liveTimelineLinked &&
- rangeAtEnd &&
- streamRenderableItemHasAfter.get(item) !== true;
+ liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
const eventJSX = reactionOrEditEvent(mEvent)
? null
@@ -1970,19 +1915,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
- const newDividerJSX =
- !isStream && newDivider && eventJSX && eventSender !== mx.getUserId() ? (
-
-
-
- {t('Room.new_messages')}
-
-
-
- ) : null;
- if (isStream && newDivider && eventJSX) {
- // TODO(P3b): replace the legacy full-width unread divider with a Stream-native
- // first-unread affordance, e.g. dot ring/pulse/brightness on the rail.
+ if (newDivider && eventJSX) {
+ // TODO(P3c-followup): replace the legacy full-width unread divider with
+ // a Stream-native first-unread affordance — dot ring/pulse/brightness on
+ // the rail. The «Jump to unread» chip stays functional in the meantime.
newDivider = false;
}
@@ -1992,34 +1928,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
return timeDayMonthYear(mEvent.getTs());
})();
- const renderDayDivider = () => {
- if (isStream) {
- return (
-
-
-
- );
- }
- return (
-
-
-
- {dayLabel}
-
-
-
- );
- };
+ const renderDayDivider = () => (
+
+
+
+ );
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
- if (eventJSX && (newDividerJSX || dayDividerJSX)) {
- if (newDividerJSX) newDivider = false;
- if (dayDividerJSX) dayDivider = false;
-
+ if (eventJSX && dayDividerJSX) {
+ dayDivider = false;
return (
- {newDividerJSX}
{dayDividerJSX}
{eventJSX}
@@ -2063,81 +1983,43 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
)}
- {(canPaginateBack || !rangeAtStart) &&
- (messageLayout === MessageLayout.Compact ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
-
-
-
-
- >
- ))}
+ {(canPaginateBack || !rangeAtStart) && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
{getItems().map(eventRenderer)}
- {(!liveTimelineLinked || !rangeAtEnd) &&
- (messageLayout === MessageLayout.Compact ? (
- <>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
-
-
-
-
-
- >
- ))}
+ {(!liveTimelineLinked || !rangeAtEnd) && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index f584f0ce..39626aed 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -27,13 +27,15 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk';
import { useStateEvent } from '../../hooks/useStateEvent';
+import { mDirectAtom } from '../../state/mDirectList';
+import { isBridgedRoom } from '../../utils/room';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
+import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
@@ -324,12 +326,28 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const space = useSpaceOptionally();
const [menuAnchor, setMenuAnchor] = useState();
const [pinMenuAnchor, setPinMenuAnchor] = useState();
- const direct = useIsDirectRoom();
+ const isOneOnOne = useIsOneOnOne();
+ // Call surface is intentionally narrower than 1:1 chrome. Three gates:
+ // 1. `isOneOnOne` — only strictly 2-member non-space rooms; group calls
+ // ship as a separate plan after channels.md.
+ // 2. `mDirectAtom.has(roomId)` — aligns visibility with the lifecycle
+ // hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which
+ // still gate ring delivery and caller-side auto-hangup on `m.direct`.
+ // Moving the lifecycle to a member-count gate is a separate plan
+ // because those hooks are load-bearing per dm_1x1_redesign.md §7.
+ // 3. `!isBridgedRoom(room)` — defense in depth for bridged DMs (mautrix-
+ // telegram puppet rooms, etc.). Bridged networks like Telegram have no
+ // Matrix-RTC equivalent, so even if a bridge config writes `m.direct`
+ // for its puppet rooms (`bridge.sync_direct_chat_list: true`) we must
+ // not expose a call button that physically can't connect.
+ const mDirects = useAtomValue(mDirectAtom);
+ const callButtonVisible =
+ isOneOnOne && mDirects.has(room.roomId) && !isBridgedRoom(room);
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
- const avatarMxc = useRoomAvatar(room, direct);
+ const avatarMxc = useRoomAvatar(room, isOneOnOne);
const name = useRoomName(room);
const topic = useRoomTopic(room);
const avatarUrl = avatarMxc
@@ -441,8 +459,14 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
- {direct && }
- {!encryptedRoom && (
+ {callButtonVisible && }
+ {/* In-room search currently only resolves when there's a parent
+ space — the legacy /home/search redirect drops the `?rooms=…`
+ query, so a non-space click would land on /direct/ with no
+ search UI mounted. P4 lifts search into the `…` menu and the
+ global SearchModalRenderer; until then we hide the chrome
+ outside spaces. */}
+ {!encryptedRoom && space && (
void;
@@ -670,8 +653,6 @@ export type MessageProps = {
canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
- messageLayout: MessageLayout;
- messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler;
onUsernameClick: MouseEventHandler;
onReplyClick: (
@@ -691,7 +672,6 @@ export type MessageProps = {
dateFormatString: string;
streamRailStart?: boolean;
streamRailEnd?: boolean;
- isStream?: boolean;
};
export const Message = as<'div', MessageProps>(
(
@@ -707,8 +687,6 @@ export const Message = as<'div', MessageProps>(
canPinEvent,
imagePackRooms,
relations,
- messageLayout,
- messageSpacing,
onUserClick,
onUsernameClick,
onReplyClick,
@@ -725,7 +703,6 @@ export const Message = as<'div', MessageProps>(
dateFormatString,
streamRailStart,
streamRailEnd,
- isStream = false,
children,
...props
},
@@ -733,7 +710,6 @@ export const Message = as<'div', MessageProps>(
) => {
const { t } = useTranslation();
const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
const senderId = mEvent.getSender() ?? '';
const [hover, setHover] = useState(false);
@@ -745,111 +721,16 @@ export const Message = as<'div', MessageProps>(
const isOwnMessage = senderId === mx.getUserId();
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
- const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const tagColor = memberPowerTag?.color
? accessibleTagColors?.get(memberPowerTag.color)
: undefined;
- const tagIconSrc = memberPowerTag?.icon
- ? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
- : undefined;
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
- const isBubble = messageLayout === MessageLayout.Bubble;
- const dot = useDotColor(room, mEvent, isStream, hideReadReceipts);
+ const dot = useDotColor(room, mEvent, true, hideReadReceipts);
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
- const showTimeInHeader = !isBubble || !isOwnMessage;
-
- // headerJSX / avatarJSX / bubbleMetaJSX feed only the legacy non-Stream
- // layouts (Compact/Bubble/Modern). Skip the React-element allocation in
- // Stream rooms — Stream builds its own author chip / time strip and never
- // reads these variables.
- const headerJSX = !isStream && !collapse && (
-
-
-
-
- {senderDisplayName}
-
-
- {tagIconSrc && }
-
- {showTimeInHeader && (
-
- {messageLayout === MessageLayout.Modern && hover && (
- <>
-
- {senderId}
-
-
- |
-
- >
- )}
-
- {isOwnMessage && (
-
- )}
-
- )}
-
- );
-
- const avatarJSX = !isStream && !collapse && messageLayout !== MessageLayout.Compact && (
-
-
- }
- />
-
-
- );
-
- const bubbleMetaJSX = !isStream && isBubble && isOwnMessage && (
-
-
-
-
- );
const msgContentJSX = (
@@ -914,11 +795,9 @@ export const Message = as<'div', MessageProps>(
return (
(
)}
- {isStream ? (
-
- }
- dotColor={dot.color}
- dotOpacity={dot.opacity}
- isOwn={isOwnMessage}
- compact={isMobile}
- railStart={streamRailStart}
- railEnd={streamRailEnd}
- header={
- // Stream rows always expose the author line: it gives every dot a
- // stable visual anchor and keeps grouped messages readable.
-
-
-
- {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
-
-
-
- }
- onContextMenu={handleContextMenu}
- >
- {msgContentJSX}
-
- ) : (
- <>
- {messageLayout === MessageLayout.Compact && (
-
- {msgContentJSX}
-
- )}
- {isBubble && isOwnMessage && (
-
-
- {msgContentJSX}
-
- {bubbleMetaJSX}
-
- )}
- {isBubble && !isOwnMessage && (
-
- {msgContentJSX}
-
- )}
- {messageLayout !== MessageLayout.Compact && !isBubble && (
-
- {headerJSX}
- {msgContentJSX}
-
- )}
- >
- )}
+
+ }
+ dotColor={dot.color}
+ dotOpacity={dot.opacity}
+ isOwn={isOwnMessage}
+ compact={isMobile}
+ railStart={streamRailStart}
+ railEnd={streamRailEnd}
+ header={
+ // Stream rows always expose the author line: it gives every dot a
+ // stable visual anchor and keeps grouped messages readable.
+
+
+
+ {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
+
+
+
+ }
+ onContextMenu={handleContextMenu}
+ >
+ {msgContentJSX}
+
);
}
@@ -1247,12 +1092,8 @@ export type EventProps = {
mEvent: MatrixEvent;
highlight: boolean;
canDelete?: boolean;
- messageSpacing: MessageSpacing;
hideReadReceipts?: boolean;
showDeveloperTools?: boolean;
- streamRailStart?: boolean;
- streamRailEnd?: boolean;
- isStream?: boolean;
};
export const Event = as<'div', EventProps>(
(
@@ -1262,12 +1103,8 @@ export const Event = as<'div', EventProps>(
mEvent,
highlight,
canDelete,
- messageSpacing,
hideReadReceipts,
showDeveloperTools,
- streamRailStart,
- streamRailEnd,
- isStream = false,
children,
...props
},
@@ -1306,7 +1143,7 @@ export const Event = as<'div', EventProps>(
(
)}
-
- {children}
-
+ {children}
);
}
diff --git a/src/app/features/room/message/styles.css.ts b/src/app/features/room/message/styles.css.ts
index 6c1ca3fd..8063264c 100644
--- a/src/app/features/room/message/styles.css.ts
+++ b/src/app/features/room/message/styles.css.ts
@@ -4,9 +4,6 @@ import { DefaultReset, config, toRem } from 'folds';
export const MessageBase = style({
position: 'relative',
});
-export const MessageBaseBubbleCollapsed = style({
- paddingTop: 0,
-});
export const MessageOptionsBase = style([
DefaultReset,
@@ -24,14 +21,6 @@ export const MessageOptionsBar = style([
},
]);
-export const BubbleAvatarBase = style({
- paddingTop: 0,
-});
-
-export const MessageAvatar = style({
- cursor: 'pointer',
-});
-
export const MessageQuickReaction = style({
minWidth: toRem(32),
});
@@ -55,11 +44,3 @@ export const ReactionsContainer = style({
export const ReactionsTooltipText = style({
wordBreak: 'break-word',
});
-
-export const BubbleMeta = style({
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'flex-end',
- gap: toRem(2),
- whiteSpace: 'nowrap',
-});
diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
index cd04ba61..798fa066 100644
--- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
+++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
@@ -77,7 +77,7 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
-import { useIsDirectRoom } from '../../../hooks/useRoom';
+import { useIsOneOnOne } from '../../../hooks/useRoom';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import {
@@ -277,8 +277,7 @@ export const RoomPinMenu = forwardRef(
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
- const direct = useIsDirectRoom();
- const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
+ const isOneOnOne = useIsOneOnOne();
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
@@ -499,7 +498,7 @@ export const RoomPinMenu = forwardRef(
canPinEvent={canPinEvent}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
- legacyUsernameColor={legacyUsernameColor || direct}
+ legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx
index 943c1b5b..38e77f16 100644
--- a/src/app/features/settings/general/General.tsx
+++ b/src/app/features/settings/general/General.tsx
@@ -31,12 +31,11 @@ import FocusTrap from 'focus-trap-react';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
-import { DateFormat, MessageSpacing, settingsAtom } from '../../../state/settings';
+import { DateFormat, settingsAtom } from '../../../state/settings';
import { SettingTile } from '../../../components/setting-tile';
import { KeySymbol } from '../../../utils/key-symbol';
import { isMacOS } from '../../../utils/user-agent';
import { stopPropagation } from '../../../utils/keyboard';
-import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
@@ -533,81 +532,8 @@ function Editor() {
);
}
-function SelectMessageSpacing() {
- const [menuCords, setMenuCords] = useState();
- const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
- const messageSpacingItems = useMessageSpacingItems();
-
- const handleMenu: MouseEventHandler = (evt) => {
- setMenuCords(evt.currentTarget.getBoundingClientRect());
- };
-
- const handleSelect = (layout: MessageSpacing) => {
- setMessageSpacing(layout);
- setMenuCords(undefined);
- };
-
- return (
- <>
- }
- onClick={handleMenu}
- >
-
- {messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
-
-
- setMenuCords(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
- isKeyBackward: (evt: KeyboardEvent) =>
- evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
- {messageSpacingItems.map((item) => (
- handleSelect(item.spacing)}
- >
- {item.name}
-
- ))}
-
-
-
- }
- />
- >
- );
-}
-
function Messages() {
const { t } = useTranslation();
- const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
- settingsAtom,
- 'legacyUsernameColor'
- );
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
@@ -624,25 +550,6 @@ function Messages() {
return (
{t('Settings.messages')}
-
- }
- />
-
-
-
- }
- />
-
{
- const homeMatch = useMatch({
- path: getHomePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!homeMatch;
-};
-
-export const useHomeCreateSelected = (): boolean => {
- const match = useMatch({
- path: getHomeCreatePath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useHomeJoinSelected = (): boolean => {
- const match = useMatch({
- path: getHomeJoinPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
-
-export const useHomeSearchSelected = (): boolean => {
- const match = useMatch({
- path: getHomeSearchPath(),
- caseSensitive: true,
- end: false,
- });
-
- return !!match;
-};
diff --git a/src/app/hooks/useIsDirectStream.ts b/src/app/hooks/useIsDirectStream.ts
deleted file mode 100644
index 8bb4c73b..00000000
--- a/src/app/hooks/useIsDirectStream.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Room } from 'matrix-js-sdk';
-import { useAtomValue } from 'jotai';
-import { mDirectAtom } from '../state/mDirectList';
-import { isDirectStreamRoom } from '../utils/room';
-import { useMatrixClient } from './useMatrixClient';
-
-// Render-side DM gate. The actual four-source decision tree lives in
-// `isDirectStreamRoom` (utils/room.ts) so HomeRouteRoomProvider (cold-start
-// push redirect), DirectRouteRoomProvider (destination validation), and
-// useRoomNavigate.navigateRoom (imperative routing) can reuse the same
-// logic without re-implementing it. All four call sites must stay in sync —
-// any divergence reintroduces the cold-start race that flashes
-// JoinBeforeNavigate before the room renders.
-// Don't replace useIsDirectRoom() with this hook globally — that one is the
-// route-level context value driven by DirectRouteRoomProvider; this one is the
-// presentation-level gate for picking the Stream visual.
-export const useIsDirectStream = (room: Room): boolean => {
- const mx = useMatrixClient();
- const mDirects = useAtomValue(mDirectAtom);
- return isDirectStreamRoom(mx, room, mDirects);
-};
diff --git a/src/app/hooks/useIsOneOnOneRoom.ts b/src/app/hooks/useIsOneOnOneRoom.ts
new file mode 100644
index 00000000..4ca66331
--- /dev/null
+++ b/src/app/hooks/useIsOneOnOneRoom.ts
@@ -0,0 +1,34 @@
+import { useEffect, useState } from 'react';
+import { Room, RoomStateEvent } from 'matrix-js-sdk';
+import { isOneOnOneRoom } from '../utils/room';
+
+// Reactive «is this room strictly 2 members» — re-runs whenever a member event
+// lands so live demotions (1:1 → 3-person via invite) and promotions (peer
+// leaves → solo) flip the chrome immediately. Without the subscription, the
+// `IsOneOnOneProvider` value would freeze at mount-time for the active route
+// and `DmCallButton` / peer-avatar fallback / membership-sysline gate would
+// stay stale until the user navigates away.
+//
+// `RoomStateEvent.Members` is emitted on every `m.room.member` state event
+// (join, invite, leave, knock, ban, profile change). Profile-only updates are
+// no-ops here because they don't change `getInvitedAndJoinedMemberCount()`.
+export const useIsOneOnOneRoom = (room: Room): boolean => {
+ const [value, setValue] = useState(() => isOneOnOneRoom(room));
+
+ useEffect(() => {
+ // Capture `currentState` once per effect run so the cleanup detaches
+ // from the same emitter we attached to. matrix-js-sdk replaces
+ // `room.currentState` on a fresh `/sync` snapshot in rare cases (room
+ // upgrade + late state catch-up); without the capture, the cleanup would
+ // call removeListener on the new state and leak the old subscription.
+ const state = room.currentState;
+ setValue(isOneOnOneRoom(room));
+ const handler = () => setValue(isOneOnOneRoom(room));
+ state.on(RoomStateEvent.Members, handler);
+ return () => {
+ state.removeListener(RoomStateEvent.Members, handler);
+ };
+ }, [room]);
+
+ return value;
+};
diff --git a/src/app/hooks/useMessageSpacing.ts b/src/app/hooks/useMessageSpacing.ts
deleted file mode 100644
index 62325b1e..00000000
--- a/src/app/hooks/useMessageSpacing.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { useMemo } from 'react';
-import { MessageSpacing } from '../state/settings';
-
-export type MessageSpacingItem = {
- name: string;
- spacing: MessageSpacing;
-};
-
-export const useMessageSpacingItems = (): MessageSpacingItem[] =>
- useMemo(
- () => [
- {
- spacing: '0',
- name: 'None',
- },
- {
- spacing: '100',
- name: 'Ultra Small',
- },
- {
- spacing: '200',
- name: 'Extra Small',
- },
- {
- spacing: '300',
- name: 'Small',
- },
- {
- spacing: '400',
- name: 'Normal',
- },
- {
- spacing: '500',
- name: 'Large',
- },
- ],
- []
- );
diff --git a/src/app/hooks/useRoom.ts b/src/app/hooks/useRoom.ts
index 4041887d..e0579352 100644
--- a/src/app/hooks/useRoom.ts
+++ b/src/app/hooks/useRoom.ts
@@ -11,12 +11,13 @@ export function useRoom(): Room {
return room;
}
-const IsDirectRoomContext = createContext(false);
+// «Strictly two-member room» — Element-Web tier-2 pattern via
+// `room.getInvitedAndJoinedMemberCount() === 2`. Server-side authoritative,
+// no race against `m.direct` account-data hydration. Drives peer-avatar
+// fallback in the room header, DM call button visibility, and per-author
+// username colour in reply chips. See docs/plans/dm_1x1_redesign.md §6.8.
+const IsOneOnOneContext = createContext(false);
-export const IsDirectRoomProvider = IsDirectRoomContext.Provider;
+export const IsOneOnOneProvider = IsOneOnOneContext.Provider;
-export const useIsDirectRoom = () => {
- const direct = useContext(IsDirectRoomContext);
-
- return direct;
-};
+export const useIsOneOnOne = () => useContext(IsOneOnOneContext);
diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts
index 95579784..d37c4ee9 100644
--- a/src/app/hooks/useRoomNavigate.ts
+++ b/src/app/hooks/useRoomNavigate.ts
@@ -2,16 +2,10 @@ import { useCallback, useRef } from 'react';
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
import { useAtomValue } from 'jotai';
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
-import {
- getDirectRoomPath,
- getHomeRoomPath,
- getSpacePath,
- getSpaceRoomPath,
-} from '../pages/pathUtils';
+import { getDirectRoomPath, getSpacePath, getSpaceRoomPath } from '../pages/pathUtils';
import { useMatrixClient } from './useMatrixClient';
-import { getOrphanParents, guessPerfectParent, isDirectStreamRoom } from '../utils/room';
+import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
import { roomToParentsAtom } from '../state/room/roomToParents';
-import { mDirectAtom } from '../state/mDirectList';
import { useSelectedSpace } from './router/useSelectedSpace';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
@@ -21,7 +15,6 @@ export const useRoomNavigate = () => {
const location = useLocation();
const mx = useMatrixClient();
const roomToParents = useAtomValue(roomToParentsAtom);
- const mDirects = useAtomValue(mDirectAtom);
const spaceSelectedId = useSelectedSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
@@ -88,23 +81,18 @@ export const useRoomNavigate = () => {
return;
}
- // Mirror the four-source DM gate used by useIsDirectStream and
- // HomeRouteRoomProvider so imperative navigation (push tap, search
- // result, inbox click) sends DMs to /direct/ even on the first frame
- // before mDirectAtom has hydrated. Fall back to the simple atom check
- // when the SDK doesn't know the room yet (e.g. peeking).
+ // After P3c the Direct tab is universal — every non-space leaf room
+ // routes through /direct/. Space roots stay on their own /{spaceId}/
+ // route handled by navigateSpace.
const targetRoom = mx.getRoom(roomId);
- const isDirect = targetRoom
- ? isDirectStreamRoom(mx, targetRoom, mDirects)
- : mDirects.has(roomId);
- if (isDirect) {
- safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
+ if (targetRoom && isSpace(targetRoom)) {
+ safeNavigate(getSpacePath(roomIdOrAlias), opts);
return;
}
- safeNavigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
+ safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
},
- [mx, safeNavigate, spaceSelectedId, roomToParents, mDirects, developerTools]
+ [mx, safeNavigate, spaceSelectedId, roomToParents, developerTools]
);
return {
diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx
index 13d6de8c..045ed0b1 100644
--- a/src/app/pages/Router.tsx
+++ b/src/app/pages/Router.tsx
@@ -48,7 +48,7 @@ import {
} from './pathUtils';
import { getMxIdServer, isUserId } from '../utils/matrix';
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
-import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
+import { HomeRouteRoomProvider } from './client/home';
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
@@ -71,7 +71,6 @@ import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotifica
import { SpaceSettingsRenderer } from '../features/space-settings';
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
import { CreateRoomModalRenderer } from '../features/create-room';
-import { HomeCreateRoom } from './client/home/CreateRoom';
import { Create } from './client/create';
import { CreateSpaceModalRenderer } from '../features/create-space';
import { SearchModalRenderer } from '../features/search';
@@ -201,24 +200,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
>
-
-
-
- }
- >
-
-
- }
- >
- {mobile ? null : } />}
- } />
- join
} />
- } />
+ {/* Legacy /home/ tree — kept only as a redirect surface so cold-start
+ push deep links and pre-P3c bookmarks resolve cleanly. The Home
+ page itself is gone; HomeRouteRoomProvider redirects /home/{roomId}/
+ into /direct/{roomId}/ on mount. See plan §6.7 / §8 P3c. */}
+
+ } />
+ } />
+ } />
+ } />
-
diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx
index c3c81bb4..41be73cc 100644
--- a/src/app/pages/client/direct/RoomProvider.tsx
+++ b/src/app/pages/client/direct/RoomProvider.tsx
@@ -1,35 +1,37 @@
import React, { ReactNode } from 'react';
+import { Room } from 'matrix-js-sdk';
import { useParams } from 'react-router-dom';
-import { useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
+import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { isDirectStreamRoom } from '../../../utils/room';
+import { isSpace } from '../../../utils/room';
+import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
+
+// Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside
+// the early-return path of the parent, so we split the component once `room`
+// is known to exist and not be a space.
+function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactNode }) {
+ const isOneOnOne = useIsOneOnOneRoom(room);
+ return (
+
+ {children}
+
+ );
+}
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
- const mDirects = useAtomValue(mDirectAtom);
const { roomIdOrAlias, eventId } = useParams();
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
- // Symmetric with HomeRouteRoomProvider's redirect predicate and
- // useRoomNavigate's destination check — all three use the same four-source
- // `isDirectStreamRoom` helper. Without this, on cold-start race (mDirectAtom
- // hydrated from useEffect, still empty on the first render after a fresh
- // invite) HomeRouteRoomProvider redirects to /direct/, this validator then
- // disagrees on the same frame and falls through to JoinBeforeNavigate. See
- // docs/plans/dm_1x1_redesign.md §6.5 / §6.7.
- if (!room || !isDirectStreamRoom(mx, room, mDirects)) {
+ // After P3c the Direct tab is universal — any joined non-space room renders
+ // here. Spaces (= future Channels) keep their own /{spaceId}/ route.
+ if (!room || isSpace(room)) {
return ;
}
- return (
-
- {children}
-
- );
+ return {children} ;
}
diff --git a/src/app/pages/client/direct/useDirectRooms.ts b/src/app/pages/client/direct/useDirectRooms.ts
index 00009927..e0ab97a7 100644
--- a/src/app/pages/client/direct/useDirectRooms.ts
+++ b/src/app/pages/client/direct/useDirectRooms.ts
@@ -1,12 +1,52 @@
import { useAtomValue } from 'jotai';
+import { useMemo } from 'react';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
-import { useDirects } from '../../../state/hooks/roomList';
+import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
+import { roomToParentsAtom } from '../../../state/room/roomToParents';
-export const useDirectRooms = () => {
+// After P3c the Direct tab is universal: every joined non-space «orphan»
+// room renders here, regardless of `m.direct`. Implementation =
+// `useOrphanRooms ∪ useDirects`:
+//
+// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
+// non-space rooms that don't live inside any space and aren't m.direct-
+// tagged. The formerly-Home tab.
+// - `useDirects` = `isRoom && mDirects.has` → every m.direct-tagged
+// non-space room. **No `roomToParents` filter** — a room that is BOTH a
+// space child AND m.direct-tagged appears here. This is intentional and
+// pre-dates P3c: the m.direct flag wins over the space-child membership
+// for routing purposes (user sees the DM in /direct/, opening it loses
+// space context). The same room is hidden from the parent space's child-
+// room navigation list (`useChildRoomScopeFactory` excludes m.direct);
+// it stays reachable from space message search via
+// `useRecursiveChildNonSpaceRoomScopeFactory` (post-P3c universal).
+//
+// Duplicates inside the union are deduped via Set semantics below. The
+// product call (DM-tagged-space-child resolves to /direct/, not /space/{id}/)
+// is open for revision — see desired_features §21 (Channels plan).
+export const useDirectRooms = (): string[] => {
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
+ const roomToParents = useAtomValue(roomToParentsAtom);
+ const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
const directs = useDirects(mx, allRoomsAtom, mDirects);
- return directs;
+ return useMemo(() => {
+ const seen = new Set();
+ const out: string[] = [];
+ orphanRooms.forEach((id) => {
+ if (!seen.has(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ });
+ directs.forEach((id) => {
+ if (!seen.has(id)) {
+ seen.add(id);
+ out.push(id);
+ }
+ });
+ return out;
+ }, [orphanRooms, directs]);
};
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
deleted file mode 100644
index 7c5a346b..00000000
--- a/src/app/pages/client/home/CreateRoom.tsx
+++ /dev/null
@@ -1,58 +0,0 @@
-import React from 'react';
-import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
-import { useTranslation } from 'react-i18next';
-import {
- Page,
- PageContent,
- PageContentCenter,
- PageHeader,
- PageHero,
- PageHeroSection,
-} from '../../../components/page';
-import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
-import { BackRouteHandler } from '../../../components/BackRouteHandler';
-import { CreateRoomForm } from '../../../features/create-room';
-import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
-
-export function HomeCreateRoom() {
- const { t } = useTranslation();
- const screenSize = useScreenSizeContext();
-
- const { navigateRoom } = useRoomNavigate();
-
- return (
-
- {screenSize === ScreenSize.Mobile && (
-
-
-
- {(onBack) => (
-
-
-
- )}
-
-
-
- )}
-
-
-
-
-
-
- }
- title={t('Home.create_room')}
- subTitle={t('Home.create_room_subtitle')}
- />
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx
deleted file mode 100644
index 9d53012c..00000000
--- a/src/app/pages/client/home/Home.tsx
+++ /dev/null
@@ -1,367 +0,0 @@
-import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import {
- Avatar,
- Box,
- Button,
- Icon,
- IconButton,
- Icons,
- Menu,
- MenuItem,
- PopOut,
- RectCords,
- Text,
- config,
- toRem,
-} from 'folds';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { useAtom, useAtomValue } from 'jotai';
-import FocusTrap from 'focus-trap-react';
-import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
-import {
- NavButton,
- NavCategory,
- NavCategoryHeader,
- NavEmptyCenter,
- NavEmptyLayout,
- NavItem,
- NavItemContent,
- NavLink,
-} from '../../../components/nav';
-import {
- encodeSearchParamValueArray,
- getExplorePath,
- getHomeCreatePath,
- getHomeRoomPath,
- getHomeSearchPath,
- withSearchParam,
-} from '../../pathUtils';
-import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
-import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import {
- useHomeCreateSelected,
- useHomeSearchSelected,
-} from '../../../hooks/router/useHomeSelected';
-import { useHomeRooms } from './useHomeRooms';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { VirtualTile } from '../../../components/virtualizer';
-import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
-import { makeNavCategoryId } from '../../../state/closedNavCategories';
-import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
-import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
-import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
-import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
-import { useRoomsUnread } from '../../../state/hooks/unread';
-import { markAsRead } from '../../../utils/notifications';
-import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
-import { stopPropagation } from '../../../utils/keyboard';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import {
- getRoomNotificationMode,
- useRoomsNotificationPreferencesContext,
-} from '../../../hooks/useRoomsNotificationPreferences';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { JoinAddressPrompt } from '../../../components/join-address-prompt';
-import { _RoomSearchParams } from '../../paths';
-
-type HomeMenuProps = {
- requestClose: () => void;
-};
-const HomeMenu = forwardRef(({ requestClose }, ref) => {
- const { t } = useTranslation();
- const orphanRooms = useHomeRooms();
- const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
- const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
- const mx = useMatrixClient();
-
- const handleMarkAsRead = () => {
- if (!unread) return;
- orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
- requestClose();
- };
-
- return (
-
-
- }
- radii="300"
- aria-disabled={!unread}
- >
-
- {t('Home.mark_as_read')}
-
-
-
-
- );
-});
-
-function HomeHeader() {
- const { t } = useTranslation();
- const [menuAnchor, setMenuAnchor] = useState();
-
- const handleOpenMenu: MouseEventHandler = (evt) => {
- const cords = evt.currentTarget.getBoundingClientRect();
- setMenuAnchor((currentState) => {
- if (currentState) return undefined;
- return cords;
- });
- };
-
- return (
- <>
-
-
-
-
- {t('Home.home')}
-
-
-
-
-
-
-
-
-
- setMenuAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- setMenuAnchor(undefined)} />
-
- }
- />
- >
- );
-}
-
-function HomeEmpty() {
- const { t } = useTranslation();
- const navigate = useNavigate();
-
- return (
-
- }
- title={
-
- {t('Home.no_rooms')}
-
- }
- content={
-
- {t('Home.no_rooms_desc')}
-
- }
- options={
- <>
- navigate(getHomeCreatePath())} variant="Secondary" size="300">
-
- {t('Home.create_room')}
-
-
- navigate(getExplorePath())}
- variant="Secondary"
- fill="Soft"
- size="300"
- >
-
- {t('Home.explore_community')}
-
-
- >
- }
- />
-
- );
-}
-
-const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
-export function Home() {
- const { t } = useTranslation();
- const mx = useMatrixClient();
- useNavToActivePathMapper('home');
- const scrollRef = useRef(null);
- const rooms = useHomeRooms();
- const notificationPreferences = useRoomsNotificationPreferencesContext();
- const roomToUnread = useAtomValue(roomToUnreadAtom);
- const navigate = useNavigate();
-
- const selectedRoomId = useSelectedRoom();
- const createRoomSelected = useHomeCreateSelected();
- const searchSelected = useHomeSearchSelected();
- const noRoomToDisplay = rooms.length === 0;
- const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
-
- const sortedRooms = useMemo(() => {
- const items = Array.from(rooms).sort(
- closedCategories.has(DEFAULT_CATEGORY_ID)
- ? factoryRoomIdByActivity(mx)
- : factoryRoomIdByAtoZ(mx)
- );
- if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
- return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
- }
- return items;
- }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
-
- const virtualizer = useVirtualizer({
- count: sortedRooms.length,
- getScrollElement: () => scrollRef.current,
- estimateSize: () => 38,
- overscan: 10,
- });
-
- const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
- closedCategories.has(categoryId)
- );
-
- return (
-
-
- {noRoomToDisplay ? (
-
- ) : (
-
-
-
-
- navigate(getHomeCreatePath())}>
-
-
-
-
-
-
-
- {t('Home.create_room')}
-
-
-
-
-
-
-
- {(open, setOpen) => (
- <>
-
- setOpen(true)}>
-
-
-
-
-
-
-
- {t('Home.join_with_address')}
-
-
-
-
-
-
- {open && (
- setOpen(false)}
- onOpen={(roomIdOrAlias, viaServers, eventId) => {
- setOpen(false);
- const path = getHomeRoomPath(roomIdOrAlias, eventId);
- navigate(
- viaServers
- ? withSearchParam<_RoomSearchParams>(path, {
- viaServers: encodeSearchParamValueArray(viaServers),
- })
- : path
- );
- }}
- />
- )}
- >
- )}
-
-
-
-
-
-
-
-
-
-
- {t('Home.message_search')}
-
-
-
-
-
-
-
-
-
-
- {t('Home.rooms')}
-
-
-
- {virtualizer.getVirtualItems().map((vItem) => {
- const roomId = sortedRooms[vItem.index];
- const room = mx.getRoom(roomId);
- if (!room) return null;
- const selected = selectedRoomId === roomId;
-
- return (
-
-
-
- );
- })}
-
-
-
-
- )}
-
- );
-}
diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx
index 5287f654..c2c7b675 100644
--- a/src/app/pages/client/home/RoomProvider.tsx
+++ b/src/app/pages/client/home/RoomProvider.tsx
@@ -1,50 +1,47 @@
import React, { ReactNode } from 'react';
-import { Navigate, useParams } from 'react-router-dom';
import { useAtomValue } from 'jotai';
+import { Navigate, useParams } from 'react-router-dom';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
-import { useHomeRooms } from './useHomeRooms';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
-import { mDirectAtom } from '../../../state/mDirectList';
+import { allRoomsAtom } from '../../../state/room-list/roomList';
import { getDirectRoomPath } from '../../pathUtils';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
-import { isDirectStreamRoom } from '../../../utils/room';
+import { isSpace } from '../../../utils/room';
-export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
+export function HomeRouteRoomProvider({ children: _children }: { children: ReactNode }) {
const mx = useMatrixClient();
- const rooms = useHomeRooms();
- const mDirects = useAtomValue(mDirectAtom);
+ // **Subscribe to allRoomsAtom even though we don't use the value**: cold-
+ // start push opens `/home/{newRoomId}/` before /sync brings the brand-new
+ // invite. On first render `mx.getRoom(roomId)` returns null, the provider
+ // falls through to JoinBeforeNavigate, and JoinBeforeNavigate has no auto-
+ // redirect — user is stuck staring at a RoomCard. The pre-P3c version
+ // pulled `useHomeRooms()` here, which transitively read `allRoomsAtom`
+ // through jotai and re-rendered when /sync populated the room. P3c removed
+ // `useHomeRooms` along with the Home page; we restore the subscription
+ // explicitly so the redirect re-evaluates after /sync. See plan §6.7 / §6.8
+ // / round-5 review notes.
+ useAtomValue(allRoomsAtom);
const { roomIdOrAlias, eventId } = useParams();
const viaServers = useSearchParamsViaServers();
const roomId = useSelectedRoom();
const room = mx.getRoom(roomId);
- // Cold-start push routing lands on /home/{roomId} (sw.ts has no access to
- // mDirectAtom). For DM rooms we redirect to /direct/. The shared four-source
- // predicate matches the render-side `useIsDirectStream` so the redirect
- // decision and the Stream layout decision agree on the first paint instead
- // of one frame later. See plan §6.5 / §6.7.
- if (room && isDirectStreamRoom(mx, room, mDirects)) {
+ // After P3c the Home tab is gone — every non-space room redirects to the
+ // unified Direct list. /home/{roomId}/ stays as a deep-link compatibility
+ // shim for cold-start push (sw.ts cannot read mDirectAtom).
+ if (room && !isSpace(room)) {
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
return ;
}
- if (!room || !rooms.includes(room.roomId)) {
- return (
-
- );
- }
-
return (
-
- {children}
-
+
);
}
diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx
deleted file mode 100644
index f0898109..00000000
--- a/src/app/pages/client/home/Search.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import React, { useRef } from 'react';
-import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
-import { useTranslation } from 'react-i18next';
-import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
-import { MessageSearch } from '../../../features/message-search';
-import { useHomeRooms } from './useHomeRooms';
-import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
-import { BackRouteHandler } from '../../../components/BackRouteHandler';
-
-export function HomeSearch() {
- const { t } = useTranslation();
- const scrollRef = useRef(null);
- const rooms = useHomeRooms();
- const screenSize = useScreenSizeContext();
-
- return (
-
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
- {(onBack) => (
-
-
-
- )}
-
- )}
-
-
- {screenSize !== ScreenSize.Mobile && }
-
- {t('Search.message_search')}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts
index 80764481..148626af 100644
--- a/src/app/pages/client/home/index.ts
+++ b/src/app/pages/client/home/index.ts
@@ -1,3 +1 @@
-export * from './Home';
-export * from './Search';
export * from './RoomProvider';
diff --git a/src/app/pages/client/home/useHomeRooms.ts b/src/app/pages/client/home/useHomeRooms.ts
deleted file mode 100644
index b0181b08..00000000
--- a/src/app/pages/client/home/useHomeRooms.ts
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useAtomValue } from 'jotai';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { roomToParentsAtom } from '../../../state/room/roomToParents';
-import { allRoomsAtom } from '../../../state/room-list/roomList';
-import { useOrphanRooms } from '../../../state/hooks/roomList';
-
-export const useHomeRooms = () => {
- const mx = useMatrixClient();
- const mDirects = useAtomValue(mDirectAtom);
- const roomToParents = useAtomValue(roomToParentsAtom);
- const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
- return rooms;
-};
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index c90e1f01..d1a78dd5 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -40,6 +40,7 @@ import {
getMemberAvatarMxc,
getMemberDisplayName,
getRoomAvatarUrl,
+ isOneOnOneRoom,
} from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
import { useInterval } from '../../../hooks/useInterval';
@@ -90,7 +91,6 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
import { useTheme } from '../../../hooks/useTheme';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
-import { mDirectAtom } from '../../../state/mDirectList';
import {
getPowerTagIconSrc,
useAccessiblePowerTagColors,
@@ -569,11 +569,9 @@ export function Notifications() {
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
- const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
const screenSize = useScreenSizeContext();
- const mDirects = useAtomValue(mDirectAtom);
const { navigateRoom } = useRoomNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -734,7 +732,7 @@ export function Notifications() {
hideActivity={hideActivity}
onOpen={navigateRoom}
legacyUsernameColor={
- legacyUsernameColor || mDirects.has(groupRoom.roomId)
+ isOneOnOneRoom(groupRoom)
}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx
index 749408dc..91d31198 100644
--- a/src/app/pages/client/sidebar/DirectTab.tsx
+++ b/src/app/pages/client/sidebar/DirectTab.tsx
@@ -4,10 +4,7 @@ import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRe
import { useTranslation } from 'react-i18next';
import FocusTrap from 'focus-trap-react';
import { useAtomValue } from 'jotai';
-import { useDirects } from '../../../state/hooks/roomList';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { allRoomsAtom } from '../../../state/room-list/roomList';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { getDirectPath, joinPathComponent } from '../../pathUtils';
import { isNativePlatform } from '../../../utils/capacitor';
@@ -66,12 +63,14 @@ const DirectMenu = forwardRef(({ requestClose }
export function DirectTab() {
const { t } = useTranslation();
const navigate = useNavigate();
- const mx = useMatrixClient();
const screenSize = useScreenSizeContext();
const navToActivePath = useAtomValue(useNavToActivePathAtom());
- const mDirects = useAtomValue(mDirectAtom);
- const directs = useDirects(mx, allRoomsAtom, mDirects);
+ // After P3c the Direct tab is universal — every joined non-space room lives
+ // here. The badge / mark-as-read scope must mirror the panel itself
+ // (`useDirectRooms()` = orphan ∪ m.direct), otherwise unread groups would
+ // count toward the panel but not the sidebar dot.
+ const directs = useDirectRooms();
const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
const [menuAnchor, setMenuAnchor] = useState();
diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx
deleted file mode 100644
index bfb689d9..00000000
--- a/src/app/pages/client/sidebar/HomeTab.tsx
+++ /dev/null
@@ -1,150 +0,0 @@
-import React, { MouseEventHandler, forwardRef, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
-import { useTranslation } from 'react-i18next';
-import { useAtomValue } from 'jotai';
-import FocusTrap from 'focus-trap-react';
-import { useOrphanRooms } from '../../../state/hooks/roomList';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { mDirectAtom } from '../../../state/mDirectList';
-import { roomToParentsAtom } from '../../../state/room/roomToParents';
-import { allRoomsAtom } from '../../../state/room-list/roomList';
-import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
-import { getHomePath, joinPathComponent } from '../../pathUtils';
-import { isNativePlatform } from '../../../utils/capacitor';
-import { useRoomsUnread } from '../../../state/hooks/unread';
-import {
- SidebarAvatar,
- SidebarItem,
- SidebarItemBadge,
- SidebarItemTooltip,
-} from '../../../components/sidebar';
-import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
-import { UnreadBadge } from '../../../components/unread-badge';
-import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
-import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
-import { useHomeRooms } from '../home/useHomeRooms';
-import { markAsRead } from '../../../utils/notifications';
-import { stopPropagation } from '../../../utils/keyboard';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-
-type HomeMenuProps = {
- requestClose: () => void;
-};
-const HomeMenu = forwardRef(({ requestClose }, ref) => {
- const { t } = useTranslation();
- const orphanRooms = useHomeRooms();
- const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
- const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
- const mx = useMatrixClient();
-
- const handleMarkAsRead = () => {
- if (!unread) return;
- orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
- requestClose();
- };
-
- return (
-
-
- }
- radii="300"
- aria-disabled={!unread}
- >
-
- {t('Home.mark_as_read')}
-
-
-
-
- );
-});
-
-export function HomeTab() {
- const { t } = useTranslation();
- const navigate = useNavigate();
- const mx = useMatrixClient();
- const screenSize = useScreenSizeContext();
- const navToActivePath = useAtomValue(useNavToActivePathAtom());
-
- const mDirects = useAtomValue(mDirectAtom);
- const roomToParents = useAtomValue(roomToParentsAtom);
- const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
- const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
- const homeSelected = useHomeSelected();
- const [menuAnchor, setMenuAnchor] = useState();
-
- const handleHomeClick = () => {
- // On native, tab tap is "switch tab", not "stack on top" — replace keeps
- // the app back-stack (see useAndroidBackButton) reflecting actual content
- // navigation, not tab toggles. Same in Direct/Inbox/Explore/Space tabs.
- // On web we keep PUSH so browser-back behaves the way desktop users
- // expect: back undoes the tab switch.
- const navOpts = { replace: isNativePlatform() };
- const activePath = navToActivePath.get('home');
- if (activePath && screenSize !== ScreenSize.Mobile) {
- navigate(joinPathComponent(activePath), navOpts);
- return;
- }
-
- navigate(getHomePath(), navOpts);
- };
-
- const handleContextMenu: MouseEventHandler = (evt) => {
- evt.preventDefault();
- const cords = evt.currentTarget.getBoundingClientRect();
- setMenuAnchor((currentState) => {
- if (currentState) return undefined;
- return cords;
- });
- };
-
- return (
-
-
- {(triggerRef) => (
-
-
-
- )}
-
- {homeUnread && (
- 0}>
- 0} count={homeUnread.total} />
-
- )}
- {menuAnchor && (
- setMenuAnchor(undefined),
- clickOutsideDeactivates: true,
- isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
- isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
- escapeDeactivates: stopPropagation,
- }}
- >
- setMenuAnchor(undefined)} />
-
- }
- />
- )}
-
- );
-}
diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts
index d44cfaa2..d3475c47 100644
--- a/src/app/pages/client/sidebar/index.ts
+++ b/src/app/pages/client/sidebar/index.ts
@@ -1,4 +1,3 @@
-export * from './HomeTab';
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';
diff --git a/src/app/pages/client/space/RoomProvider.tsx b/src/app/pages/client/space/RoomProvider.tsx
index 0fd52ab6..1ea76133 100644
--- a/src/app/pages/client/space/RoomProvider.tsx
+++ b/src/app/pages/client/space/RoomProvider.tsx
@@ -1,8 +1,9 @@
import React, { ReactNode } from 'react';
+import { Room } from 'matrix-js-sdk';
import { useParams } from 'react-router-dom';
import { useAtom, useAtomValue } from 'jotai';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
-import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
+import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
import { useSpace } from '../../../hooks/useSpace';
@@ -10,16 +11,27 @@ import { getAllParents, getSpaceChildren } from '../../../utils/room';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
-import { mDirectAtom } from '../../../state/mDirectList';
import { settingsAtom } from '../../../state/settings';
import { useSetting } from '../../../state/hooks/settings';
+import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
+
+// Inner provider hosts the reactive 1:1 subscription. Mirrors
+// direct/RoomProvider.tsx — the hook can't run before the early-return
+// guards, so we split the component once we know the room exists.
+function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactNode }) {
+ const isOneOnOne = useIsOneOnOneRoom(room);
+ return (
+
+ {children}
+
+ );
+}
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
const mx = useMatrixClient();
const space = useSpace();
const [developerTools] = useSetting(settingsAtom, 'developerTools');
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
- const mDirects = useAtomValue(mDirectAtom);
const allRooms = useAtomValue(allRoomsAtom);
const { roomIdOrAlias, eventId } = useParams();
@@ -39,12 +51,10 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
}
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
- // allow to view space timeline
- return (
-
- {children}
-
- );
+ // Allow viewing the space's own timeline. `isOneOnOneRoom` already guards
+ // spaces (it returns false for `isSpace(room)`), so the value is stable
+ // here regardless of member count.
+ return {children} ;
}
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
@@ -66,9 +76,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
- return (
-
- {children}
-
- );
+ return {children} ;
}
diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx
index f895acda..0d85bf86 100644
--- a/src/app/pages/client/space/Search.tsx
+++ b/src/app/pages/client/space/Search.tsx
@@ -5,9 +5,11 @@ import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { MessageSearch } from '../../../features/message-search';
import { useSpace } from '../../../hooks/useSpace';
-import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
+import {
+ useRecursiveChildNonSpaceRoomScopeFactory,
+ useSpaceChildren,
+} from '../../../state/hooks/roomList';
import { allRoomsAtom } from '../../../state/room-list/roomList';
-import { mDirectAtom } from '../../../state/mDirectList';
import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
@@ -20,12 +22,15 @@ export function SpaceSearch() {
const space = useSpace();
const screenSize = useScreenSizeContext();
- const mDirects = useAtomValue(mDirectAtom);
const roomToParents = useAtomValue(roomToParentsAtom);
+ // Post-P3c: child-room search reaches every non-space room descendant,
+ // including `m.direct`-tagged ones. Pre-P3c this filter excluded DMs from
+ // space message search — that filter is interop-only after the universal
+ // Direct collapse, see plan §6.8.
const rooms = useSpaceChildren(
allRoomsAtom,
space.roomId,
- useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
+ useRecursiveChildNonSpaceRoomScopeFactory(mx, roomToParents)
);
return (
diff --git a/src/app/state/hooks/roomList.ts b/src/app/state/hooks/roomList.ts
index d64ecd5b..68ba42cb 100644
--- a/src/app/state/hooks/roomList.ts
+++ b/src/app/state/hooks/roomList.ts
@@ -83,6 +83,24 @@ export const useRecursiveChildRoomScopeFactory = (
[mx, mDirects, roomToParents]
);
+// Universal-Direct-aware child-room scope (P3c). Mirrors
+// `useRecursiveChildRoomScopeFactory` but drops the `m.direct` exclusion so
+// space message search can reach `m.direct`-tagged child rooms — after P3c
+// `m.direct` is interop-only, not a UI classifier (see plan §6.8). Use this
+// for «search across all non-space rooms reachable from a space root», not
+// the legacy filter-DMs-out behaviour.
+export const useRecursiveChildNonSpaceRoomScopeFactory = (
+ mx: MatrixClient,
+ roomToParents: RoomToParents
+): SpaceChildSelectorFactory =>
+ useCallback(
+ (parentId: string) => (roomId) =>
+ isRoom(mx.getRoom(roomId)) &&
+ roomToParents.has(roomId) &&
+ getAllParents(roomToParents, roomId).has(parentId),
+ [mx, roomToParents]
+ );
+
export const useChildDirectScopeFactory = (
mx: MatrixClient,
mDirects: Set,
@@ -147,6 +165,19 @@ export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set {
+ const selector: RoomSelector = useCallback(
+ (roomId: string) => isRoom(mx.getRoom(roomId)),
+ [mx]
+ );
+ return useSelectedRooms(roomsAtom, selector);
+};
+
export const useOrphanRooms = (
mx: MatrixClient,
roomsAtom: RoomsAtom,
diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts
index 303e71f6..1bc7c28c 100644
--- a/src/app/state/settings.ts
+++ b/src/app/state/settings.ts
@@ -8,12 +8,6 @@ export type DateFormat =
| 'YYYY/MM/DD'
| 'YYYY-MM-DD'
| '';
-export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
-export enum MessageLayout {
- Modern = 0,
- Compact = 1,
- Bubble = 2,
-}
export interface Settings {
themeId?: string;
@@ -28,15 +22,12 @@ export interface Settings {
isPeopleDrawer: boolean;
memberSortFilterIndex: number;
enterForNewline: boolean;
- messageLayout: MessageLayout;
- messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
hideNickAvatarEvents: boolean;
mediaAutoLoad: boolean;
urlPreview: boolean;
encUrlPreview: boolean;
showHiddenEvents: boolean;
- legacyUsernameColor: boolean;
isNotificationSounds: boolean;
@@ -49,6 +40,7 @@ export interface Settings {
}
const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
+const P3C_CLEANUP_KEY = 'dawn-p3c-cleanup';
const defaultSettings: Settings = {
themeId: undefined,
@@ -63,15 +55,12 @@ const defaultSettings: Settings = {
isPeopleDrawer: true,
memberSortFilterIndex: 0,
enterForNewline: false,
- messageLayout: MessageLayout.Bubble,
- messageSpacing: '400',
hideMembershipEvents: false,
hideNickAvatarEvents: true,
mediaAutoLoad: true,
urlPreview: true,
encUrlPreview: false,
showHiddenEvents: false,
- legacyUsernameColor: false,
isNotificationSounds: true,
@@ -114,6 +103,22 @@ export const getSettings = (): Settings => {
setSettings(merged);
}
+ // P3c cleanup migration: strip orphan persisted fields removed by the
+ // universal Stream collapse (`messageLayout`, `messageSpacing`,
+ // `legacyUsernameColor`). Symmetric to the Dawn migration above — synchronous
+ // sweep of stale keys at first load, stamped so it runs exactly once.
+ if (!merged.migrationsApplied?.[P3C_CLEANUP_KEY]) {
+ const orphan = merged as unknown as Record;
+ delete orphan.messageLayout;
+ delete orphan.messageSpacing;
+ delete orphan.legacyUsernameColor;
+ merged.migrationsApplied = {
+ ...(merged.migrationsApplied ?? {}),
+ [P3C_CLEANUP_KEY]: true,
+ };
+ setSettings(merged);
+ }
+
return merged;
};
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts
index 2c0d0cc1..03645c44 100644
--- a/src/app/utils/room.ts
+++ b/src/app/utils/room.ts
@@ -72,29 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool
return content?.is_direct === true;
};
-// Synchronous «is this a DM that should render in the Stream layout?» predicate.
-// Combines four first-frame-safe sources so every DM gate in the app — render
-// (`useIsDirectStream`), cold-start push redirect (HomeRouteRoomProvider),
-// imperative navigation (useRoomNavigate) — agrees on the answer:
-// 1. Live `mDirectAtom` Set (caller passes it in to keep this a pure helper).
-// 2. `room.getDMInviter()` for invite-state members with is_direct: true.
-// 3. `isDirectInvite(room, myUserId)` for current member content is_direct.
-// 4. `mx.getAccountData('m.direct')` SDK fallback for the first frame after
-// cold-start when the atom is still being hydrated by useBindMDirectAtom.
-// See docs/plans/dm_1x1_redesign.md §6.5 / §6.7.
-export const isDirectStreamRoom = (
- mx: MatrixClient,
- room: Room,
- mDirects: Set
-): boolean => {
- if (mDirects.has(room.roomId)) return true;
- if (room.getDMInviter()) return true;
- if (isDirectInvite(room, mx.getUserId())) return true;
- const event = getAccountData(mx, AccountDataEvent.Direct);
- if (event && getMDirects(event).has(room.roomId)) return true;
- return false;
-};
-
export const isSpace = (room: Room | null): boolean => {
if (!room) return false;
const event = getStateEvent(room, StateEvent.RoomCreate);
@@ -116,6 +93,35 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
};
+// «Strictly two-member non-space room» — Element-Web tier-2 pattern. Drives
+// Vojo's 1:1-vs-group split after the P3c collapse: peer-avatar fallback in
+// the room header, DM call button visibility, reply username colour,
+// membership-row suppression. Server-side authoritative (no race against
+// `m.direct` account data hydration). Counts both joined AND invited so a
+// freshly-invited 1:1 classifies correctly before the peer accepts. The
+// `isSpace` guard prevents the developer-tools «view space timeline» path
+// (space/RoomProvider.tsx) from accidentally rendering 1:1 chrome on a
+// two-member space root.
+export const isOneOnOneRoom = (room: Room): boolean =>
+ !isSpace(room) && room.getInvitedAndJoinedMemberCount() === 2;
+
+// «Is this a bridged room?» (mautrix-telegram, mautrix-whatsapp, mautrix-signal,
+// matrix-appservice-irc, etc.) — checks for the canonical MSC2346 `m.bridge`
+// state event plus the still-deployed unstable `uk.half-shot.bridge` prefix.
+// Bridges write one of these on every bridged DM/portal so the existence test
+// is unambiguous. Used to suppress surfaces that have no equivalent on the
+// bridge side — primarily `DmCallButton`: Telegram has no Matrix-RTC
+// equivalent, so even a 2-member native-feeling DM must never expose the
+// call button if the room is actually a bridge puppet. Each `m.bridge` event
+// is keyed by the bridge bot mxid (state_key); we don't care about the value,
+// only about presence.
+export const isBridgedRoom = (room: Room): boolean => {
+ const stable = getStateEvents(room, StateEvent.RoomBridge);
+ if (stable.length > 0) return true;
+ const unstable = getStateEvents(room, StateEvent.RoomBridgeUnstable);
+ return unstable.length > 0;
+};
+
export function isValidChild(mEvent: MatrixEvent): boolean {
return (
mEvent.getType() === StateEvent.SpaceChild &&
diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts
index 50edff3f..629b332e 100644
--- a/src/types/matrix/room.ts
+++ b/src/types/matrix/room.ts
@@ -34,6 +34,15 @@ export enum StateEvent {
RoomTombstone = 'm.room.tombstone',
GroupCallPrefix = 'org.matrix.msc3401.call',
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
+ // MSC2346 — bridge metadata. Both stable (`m.bridge`) and unstable
+ // (`uk.half-shot.bridge`) keys exist in the wild; mautrix-* and most
+ // long-running bridges write at least one of them. Used by Vojo to detect
+ // bridged rooms (e.g. mautrix-telegram puppet rooms) so we can suppress
+ // surfaces that don't translate across the bridge — voice calls being the
+ // most obvious example: Telegram has no Matrix-RTC equivalent, so a 1:1
+ // bridged room must never expose `DmCallButton`.
+ RoomBridge = 'm.bridge',
+ RoomBridgeUnstable = 'uk.half-shot.bridge',
SpaceChild = 'm.space.child',
SpaceParent = 'm.space.parent',