redesign(p3c): collapse Home into universal Direct, drop legacy layouts, gate room flavour on member-count, and clear orphan settings.

This commit is contained in:
v.lagerev 2026-04-28 21:52:31 +03:00
parent 1c5ecb5309
commit 7ff25c7a95
52 changed files with 641 additions and 1773 deletions

View file

@ -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 `<Outlet/>` 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 `<Outlet/>` for the active room/sub-route.
- **Auth** (`auth/login/`, `auth/register/`, `auth/reset-password/`) — NOTE: bistable layout fragility, see `bugs.md`. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. **Don't change `body`/`#root` background CSS-vars** — auth uses computed sizes for safe-area painting.
- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
- `home/`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 : <Route index element={<WelcomePage />} />}`) — 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 `<Message>` / `<Event>` 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 → `<StreamLayout/>`) 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 `<StreamLayout/>`. `Modern.tsx` survives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines. `EventContent.tsx` is a single-branch sysline renderer (the legacy `IsStreamProvider` context was deleted; rail metadata flows in via `railStart` / `railEnd` props directly).
- `message/MessageStatus.tsx` + `hooks/useMessageStatus.ts`**Kept but no longer rendered in the timeline.** P3c retired the WhatsApp-style checkmarks because the Stream-rail dot now encodes the same delivery / read state via colour and opacity. `useMessageStatus` is still consumed inside `useDotColor.ts`, so the file is load-bearing.
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
- `emoji-board/` — Emoji picker
- `image-pack-view/` — Custom emoji pack management
@ -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.

View file

@ -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",

View file

@ -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": "Отключить автозагрузку медиа",

View file

@ -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<StreamContext>({ 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<HTMLDivElement>(null);
const timeRef = useRef<HTMLDivElement>(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 (
<div
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
ref={rootRef}
>
<div
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
ref={rootRef}
className={classNames(layoutCss.StreamTimeColumn, layoutCss.StreamSyslineTimeColumn)}
ref={timeRef}
>
<div
className={classNames(layoutCss.StreamTimeColumn, layoutCss.StreamSyslineTimeColumn)}
ref={timeRef}
>
{time}
</div>
<span
className={classNames(
layoutCss.StreamRail,
railStart && railEnd && layoutCss.StreamRailSingle,
railStart && !railEnd && layoutCss.StreamSyslineRailStart,
railEnd && !railStart && layoutCss.StreamSyslineRailEnd
)}
aria-hidden
ref={railRef}
/>
<span
className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)}
aria-hidden
ref={dotRef}
>
<span
className={layoutCss.StreamDotFill}
style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }}
/>
</span>
<Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}>
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
<div className={layoutCss.StreamSyslineBody}>{content}</div>
</Box>
{/* `messageLayout` is intentionally unused in stream-mode. */}
{time}
</div>
);
}
const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === MessageLayout.Compact && time}
<Box
grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
alignItems="Center"
justifyContent="Center"
<span
className={classNames(
layoutCss.StreamRail,
railStart && railEnd && layoutCss.StreamRailSingle,
railStart && !railEnd && layoutCss.StreamSyslineRailStart,
railEnd && !railStart && layoutCss.StreamSyslineRailEnd
)}
aria-hidden
ref={railRef}
/>
<span
className={classNames(layoutCss.StreamDotHalo, layoutCss.StreamSyslineDotHalo)}
aria-hidden
ref={dotRef}
>
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
<span
className={layoutCss.StreamDotFill}
style={{ backgroundColor: color.Surface.OnContainer, opacity: 0.42 }}
/>
</span>
<Box gap="200" alignItems="Center" style={{ minWidth: 0 }} ref={bodyRef}>
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
<div className={layoutCss.StreamSyslineBody}>{content}</div>
</Box>
</Box>
</div>
);
const msgContentJSX = (
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
{content}
{messageLayout !== MessageLayout.Compact && time}
</Box>
);
if (messageLayout === MessageLayout.Compact) {
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
}
if (messageLayout === MessageLayout.Bubble) {
return (
<BubbleLayout hideBubble before={beforeJSX}>
{msgContentJSX}
</BubbleLayout>
);
}
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
}

View file

@ -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 (
<svg
className={css.BubbleLeftArrow}
width="9"
height="8"
viewBox="0 0 9 8"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
fill={color[variant].Container}
/>
</svg>
);
}
type BubbleLayoutProps = {
hideBubble?: boolean;
before?: ReactNode;
header?: ReactNode;
};
export const BubbleLayout = as<'div', BubbleLayoutProps>(
({ hideBubble, before, header, children, ...props }, ref) => (
<Box gap="300" {...props} ref={ref}>
<Box className={css.BubbleBefore} shrink="No">
{before}
</Box>
<Box grow="Yes" direction="Column">
{header}
{hideBubble ? (
children
) : (
<Box>
<Box
className={
hideBubble
? undefined
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
}
direction="Column"
>
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
{children}
</Box>
</Box>
)}
</Box>
</Box>
)
);

View file

@ -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) => (
<Box gap="200" {...props} ref={ref}>
<Box className={css.CompactHeader} gap="200" shrink="No">
{before}
</Box>
{children}
</Box>
)
);

View file

@ -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',
{

View file

@ -1,5 +1,3 @@
export * from './Modern';
export * from './Compact';
export * from './Bubble';
export * from './Stream';
export * from './Base';

View file

@ -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<typeof MessageBase>;
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',

View file

@ -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 (
<CompactLayout
{...props}
ref={ref}
before={
<>
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
</>
}
>
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
</CompactLayout>
);
}
);

View file

@ -1,3 +1,2 @@
export * from './LinePlaceholder';
export * from './CompactPlaceholder';
export * from './DefaultPlaceholder';

View file

@ -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;

View file

@ -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 (
<MenuItem

View file

@ -3,7 +3,7 @@ import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
import { useCallPreferences } from '../../state/hooks/callPreferences';
@ -14,11 +14,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
const room = useRoom();
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
const direct = useIsDirectRoom();
const isOneOnOne = useIsOneOnOne();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const startCall = useCallStart(direct);
const startCall = useCallStart(isOneOnOne);
const joining = callEmbed?.roomId === room.roomId && !callJoined;
const disabled = inOtherCall || !canJoin;

View file

@ -12,7 +12,6 @@ import {
TextArea,
} from 'folds';
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
import { useAtomValue } from 'jotai';
import { useTranslation } from 'react-i18next';
import Linkify from 'linkify-react';
import classNames from 'classnames';
@ -26,7 +25,7 @@ import {
useRoomName,
useRoomTopic,
} from '../../../hooks/useRoomMeta';
import { mDirectAtom } from '../../../state/mDirectList';
import { isOneOnOneRoom } from '../../../utils/room';
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
@ -270,9 +269,12 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const directs = useAtomValue(mDirectAtom);
const avatar = useRoomAvatar(room, directs.has(room.roomId));
// Peer-avatar fallback follows the member-count gate, mirroring the room
// header and RoomSettings (post-P3c). RoomProfile renders inside the
// RoomSettings modal which is mounted globally, so the IsOneOnOneProvider
// context isn't in scope — call the helper directly.
const avatar = useRoomAvatar(room, isOneOnOneRoom(room));
const name = useRoomName(room);
const topic = useRoomTopic(room);
const joinRule = useRoomJoinRule(room);

View file

@ -1,7 +1,6 @@
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
import { Trans, useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
@ -16,9 +15,9 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { ScrollTopContainer } from '../../components/scroll-top-container';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
import { useRooms } from '../../state/hooks/roomList';
import { useNonSpaceRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { isOneOnOneRoom } from '../../utils/room';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
@ -53,11 +52,13 @@ export function MessageSearch({
}: MessageSearchProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
// Post-P3c: search scope is «every joined non-space room», not «non-DM
// rooms». `m.direct` is interop-only after the universal Direct collapse —
// see plan §6.8 / §6.8b. Pre-P3c this was `useRooms(...)` which excluded
// `m.direct`-tagged rooms from search scope, leaving DM searches broken.
const allRooms = useNonSpaceRooms(mx, allRoomsAtom);
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');
@ -297,7 +298,7 @@ export function MessageSearch({
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
legacyUsernameColor={isOneOnOneRoom(groupRoom)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>

View file

@ -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
<Avatar size="300" radii="400">
<RoomAvatar
roomId={room.roomId}
src={getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)}
src={
isOneOnOneRoom(room)
? // 1:1 — fall back to the peer's avatar when the room
// itself has no custom one. Mirrors RoomViewHeader's
// `useRoomAvatar(room, isOneOnOne)` semantics.
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
: // Group / non-1:1 — use the room avatar only. Without
// this guard `getDirectRoomAvatarUrl` would surface the
// first non-self member's avatar in groups without a
// custom room avatar, making them look like 1:1 chats.
getRoomAvatarUrl(mx, room, 96, useAuthentication)
}
alt={roomName}
renderFallback={() => (
<Text as="span" size="H6">

View file

@ -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);

View file

@ -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<HTMLDivElement, RoomInputProps>(
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<HTMLButtonElement>(null);
const roomToParents = useAtomValue(roomToParentsAtom);
@ -159,8 +158,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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));

View file

@ -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) => (
<Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
{children}
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
</Box>
)
);
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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1178,7 +1151,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
outlineAttachment
/>
)}
</Message>
@ -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 <RedactedContent />;
@ -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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -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 = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={iconSrc}
content={
<Box grow="Yes" direction="Column">
@ -1479,7 +1446,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1533,7 +1497,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1587,7 +1548,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={Icons.Hash}
content={
<Box grow="Yes" direction="Column">
@ -1649,7 +1607,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
content={
<Box grow="Yes" direction="Column">
@ -1700,7 +1655,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
@ -1753,7 +1705,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={isStream || messageLayout === MessageLayout.Compact}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -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}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
railStart={streamRailStart}
railEnd={streamRailEnd}
iconSrc={Icons.Code}
content={
<Box grow="Yes" direction="Column">
@ -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<number, boolean>();
const after = new Map<number, boolean>();
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() ? (
<MessageBase space={messageSpacing}>
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
<Text size="L400">{t('Room.new_messages')}</Text>
</Badge>
</TimelineDivider>
</MessageBase>
) : 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 (
<MessageBase space={STREAM_MESSAGE_SPACING}>
<StreamDayDivider label={dayLabel} />
</MessageBase>
);
}
return (
<MessageBase space={messageSpacing}>
<TimelineDivider variant="Surface">
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
<Text size="L400">{dayLabel}</Text>
</Badge>
</TimelineDivider>
</MessageBase>
);
};
const renderDayDivider = () => (
<MessageBase space={STREAM_MESSAGE_SPACING}>
<StreamDayDivider label={dayLabel} />
</MessageBase>
);
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
if (newDividerJSX) newDivider = false;
if (dayDividerJSX) dayDivider = false;
if (eventJSX && dayDividerJSX) {
dayDivider = false;
return (
<React.Fragment key={mEventId}>
{newDividerJSX}
{dayDividerJSX}
{eventJSX}
</React.Fragment>
@ -2063,81 +1983,43 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
<div
style={{
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
}`,
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem(
64
)}`,
}}
>
<RoomIntro room={room} />
</div>
)}
{(canPaginateBack || !rangeAtStart) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{(canPaginateBack || !rangeAtStart) && (
<>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase ref={observeBackAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
)}
{getItems().map(eventRenderer)}
{(!liveTimelineLinked || !rangeAtEnd) &&
(messageLayout === MessageLayout.Compact ? (
<>
<MessageBase ref={observeFrontAnchor}>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<CompactPlaceholder key={getItems().length} />
</MessageBase>
</>
) : (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
))}
{(!liveTimelineLinked || !rangeAtEnd) && (
<>
<MessageBase ref={observeFrontAnchor}>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
<MessageBase>
<DefaultPlaceholder key={getItems().length} />
</MessageBase>
</>
)}
<span ref={atBottomAnchorRef} />
</Box>
</Scroll>

View file

@ -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<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
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 }) {
</Box>
<Box shrink="No">
{direct && <DmCallButton room={room} />}
{!encryptedRoom && (
{callButtonVisible && <DmCallButton room={room} />}
{/* 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 && (
<TooltipProvider
position="Bottom"
offset={4}

View file

@ -1,5 +1,4 @@
import {
Avatar,
Box,
Button,
Dialog,
@ -38,27 +37,15 @@ import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import {
AvatarBase,
BubbleLayout,
CompactLayout,
IsStreamProvider,
MessageBase,
MessageStatus,
ModernLayout,
STREAM_MESSAGE_SPACING,
StreamLayout,
Time,
Username,
UsernameBold,
} from '../../../components/message';
import {
canEditEvent,
getEventEdits,
getMemberAvatarMxc,
getMemberDisplayName,
} from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useDotColor } from '../../../hooks/useDotColor';
@ -70,17 +57,13 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
import { UserAvatar } from '../../../components/user-avatar';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
import { PowerIcon } from '../../../components/power';
import colorMXID from '../../../../util/colorMXID';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -670,8 +653,6 @@ export type MessageProps = {
canPinEvent?: boolean;
imagePackRooms?: Room[];
relations?: Relations;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
onUserClick: MouseEventHandler<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
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 && (
<Box
gap="300"
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
justifyContent="SpaceBetween"
alignItems="Baseline"
grow="Yes"
>
<Box alignItems="Center" gap="200">
<Username
as="button"
style={{ color: usernameColor }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size={isBubble ? 'T300' : 'T400'} truncate>
<UsernameBold>{senderDisplayName}</UsernameBold>
</Text>
</Username>
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
</Box>
{showTimeInHeader && (
<Box shrink="No" gap="100" alignItems="Center">
{messageLayout === MessageLayout.Modern && hover && (
<>
<Text as="span" size="T200" priority="300">
{senderId}
</Text>
<Text as="span" size="T200" priority="300">
|
</Text>
</>
)}
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
{isOwnMessage && (
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
)}
</Box>
)}
</Box>
);
const avatarJSX = !isStream && !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</AvatarBase>
);
const bubbleMetaJSX = !isStream && isBubble && isOwnMessage && (
<span className={css.BubbleMeta}>
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
</span>
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
@ -914,11 +795,9 @@ export const Message = as<'div', MessageProps>(
return (
<MessageBase
className={classNames(css.MessageBase, className, {
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
space={STREAM_MESSAGE_SPACING}
collapse={collapse}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
@ -1167,76 +1046,42 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
{isStream ? (
<StreamLayout
time={
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
}
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.
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
</StreamLayout>
) : (
<>
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</CompactLayout>
)}
{isBubble && isOwnMessage && (
<Box gap="200" alignItems="End">
<BubbleLayout
style={{ flexGrow: 1, minWidth: 0 }}
before={avatarJSX}
header={headerJSX}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
</BubbleLayout>
{bubbleMetaJSX}
</Box>
)}
{isBubble && !isOwnMessage && (
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
{msgContentJSX}
</BubbleLayout>
)}
{messageLayout !== MessageLayout.Compact && !isBubble && (
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
{headerJSX}
{msgContentJSX}
</ModernLayout>
)}
</>
)}
<StreamLayout
time={
<Time
ts={mEvent.getTs()}
compact
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
}
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.
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
</StreamLayout>
</MessageBase>
);
}
@ -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>(
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
space={STREAM_MESSAGE_SPACING}
autoCollapse
highlight={highlight}
selected={!!menuAnchor}
@ -1393,11 +1230,7 @@ export const Event = as<'div', EventProps>(
</Menu>
</div>
)}
<IsStreamProvider
value={{ enabled: isStream, railStart: streamRailStart, railEnd: streamRailEnd }}
>
<div onContextMenu={handleContextMenu}>{children}</div>
</IsStreamProvider>
<div onContextMenu={handleContextMenu}>{children}</div>
</MessageBase>
);
}

View file

@ -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',
});

View file

@ -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<HTMLDivElement, RoomPinMenuProps>(
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<HTMLDivElement, RoomPinMenuProps>(
canPinEvent={canPinEvent}
getMemberPowerTag={getMemberPowerTag}
accessibleTagColors={accessibleTagColors}
legacyUsernameColor={legacyUsernameColor || direct}
legacyUsernameColor={isOneOnOne}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>

View file

@ -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<RectCords>();
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const messageSpacingItems = useMessageSpacingItems();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (layout: MessageSpacing) => {
setMessageSpacing(layout);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{messageSpacingItems.map((item) => (
<MenuItem
key={item.spacing}
size="300"
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.spacing)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function Messages() {
const { t } = useTranslation();
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
settingsAtom,
'legacyUsernameColor'
);
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
settingsAtom,
'hideMembershipEvents'
@ -624,25 +550,6 @@ function Messages() {
return (
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.messages')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={t('Settings.message_spacing')}
description={t('Settings.message_spacing_dm_note')}
after={<SelectMessageSpacing />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={t('Settings.legacy_username_color')}
after={
<Switch
variant="Primary"
value={legacyUsernameColor}
onChange={setLegacyUsernameColor}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title={t('Settings.hide_membership')}

View file

@ -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 { JoinRule } from 'matrix-js-sdk';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
@ -8,7 +7,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 { SpaceSettingsPage } from '../../state/spaceSettings';
import { useRoom } from '../../hooks/useRoom';
@ -64,9 +63,10 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
const room = useRoom();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const mDirects = useAtomValue(mDirectAtom);
const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
// `isOneOnOneRoom` excludes spaces, so this resolves to `false` for the
// space-settings drawer — kept on the helper for symmetry with RoomSettings.
const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room));
const roomName = useRoomName(room);
const joinRuleContent = useRoomJoinRule(room);

View file

@ -1,47 +0,0 @@
import { useMatch } from 'react-router-dom';
import {
getHomeCreatePath,
getHomeJoinPath,
getHomePath,
getHomeSearchPath,
} from '../../pages/pathUtils';
export const useHomeSelected = (): boolean => {
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;
};

View file

@ -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);
};

View file

@ -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<boolean>(() => 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;
};

View file

@ -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',
},
],
[]
);

View file

@ -11,12 +11,13 @@ export function useRoom(): Room {
return room;
}
const IsDirectRoomContext = createContext<boolean>(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<boolean>(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);

View file

@ -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 {

View file

@ -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)
</AuthRouteThemeManager>
}
>
<Route
path={HOME_PATH}
element={
<PageRoot
nav={
<MobileFriendlyPageNav path={HOME_PATH}>
<Home />
</MobileFriendlyPageNav>
}
>
<Outlet />
</PageRoot>
}
>
{mobile ? null : <Route index element={<WelcomePage />} />}
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
<Route path={_JOIN_PATH} element={<p>join</p>} />
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
{/* 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. */}
<Route path={HOME_PATH}>
<Route index element={<Navigate to={DIRECT_PATH} replace />} />
<Route path={_CREATE_PATH} element={<Navigate to={getDirectCreatePath()} replace />} />
<Route path={_JOIN_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route path={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
<Route
path={_ROOM_PATH}
element={

View file

@ -9,7 +9,6 @@ import {
} from '../../components/sidebar';
import {
DirectTab,
HomeTab,
SpaceTabs,
InboxTab,
ExploreTab,
@ -28,7 +27,6 @@ export function SidebarNav() {
scrollable={
<Scroll ref={scrollRef} variant="Background" size="0">
<SidebarStack>
<HomeTab />
<DirectTab />
</SidebarStack>
<SpaceTabs scrollRef={scrollRef} />

View file

@ -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 (
<RoomProvider key={room.roomId} value={room}>
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
</RoomProvider>
);
}
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 <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
}
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
</RoomProvider>
);
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
}

View file

@ -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<string>();
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]);
};

View file

@ -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 (
<Page>
{screenSize === ScreenSize.Mobile && (
<PageHeader balance outlined={false}>
<Box grow="Yes" alignItems="Center" gap="200">
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
</Box>
</PageHeader>
)}
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<PageHeroSection>
<Box direction="Column" gap="700">
<PageHero
icon={<Icon size="600" src={Icons.Hash} />}
title={t('Home.create_room')}
subTitle={t('Home.create_room_subtitle')}
/>
<CreateRoomForm onCreate={navigateRoom} />
</Box>
</PageHeroSection>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -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<HTMLDivElement, HomeMenuProps>(({ 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 (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.mark_as_read')}
</Text>
</MenuItem>
</Box>
</Menu>
);
});
function HomeHeader() {
const { t } = useTranslation();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<>
<PageNavHeader>
<Box alignItems="Center" grow="Yes" gap="300">
<Box grow="Yes">
<Text size="H4" truncate>
{t('Home.home')}
</Text>
</Box>
<Box>
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
<Icon src={Icons.VerticalDots} size="200" />
</IconButton>
</Box>
</Box>
</PageNavHeader>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</>
);
}
function HomeEmpty() {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<NavEmptyCenter>
<NavEmptyLayout
icon={<Icon size="600" src={Icons.Hash} />}
title={
<Text size="H5" align="Center">
{t('Home.no_rooms')}
</Text>
}
content={
<Text size="T300" align="Center">
{t('Home.no_rooms_desc')}
</Text>
}
options={
<>
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
<Text size="B300" truncate>
{t('Home.create_room')}
</Text>
</Button>
<Button
onClick={() => navigate(getExplorePath())}
variant="Secondary"
fill="Soft"
size="300"
>
<Text size="B300" truncate>
{t('Home.explore_community')}
</Text>
</Button>
</>
}
/>
</NavEmptyCenter>
);
}
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
export function Home() {
const { t } = useTranslation();
const mx = useMatrixClient();
useNavToActivePathMapper('home');
const scrollRef = useRef<HTMLDivElement>(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 (
<PageNav>
<HomeHeader />
{noRoomToDisplay ? (
<HomeEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
<NavButton onClick={() => navigate(getHomeCreatePath())}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Home.create_room')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
<UseStateProvider initial={false}>
{(open, setOpen) => (
<>
<NavItem variant="Background" radii="400">
<NavButton onClick={() => setOpen(true)}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Link} size="100" />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Home.join_with_address')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
{open && (
<JoinAddressPrompt
onCancel={() => setOpen(false)}
onOpen={(roomIdOrAlias, viaServers, eventId) => {
setOpen(false);
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers
? withSearchParam<_RoomSearchParams>(path, {
viaServers: encodeSearchParamValueArray(viaServers),
})
: path
);
}}
/>
)}
</>
)}
</UseStateProvider>
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
<NavLink to={getHomeSearchPath()}>
<NavItemContent>
<Box as="span" grow="Yes" alignItems="Center" gap="200">
<Avatar size="200" radii="400">
<Icon src={Icons.Search} size="100" filled={searchSelected} />
</Avatar>
<Box as="span" grow="Yes">
<Text as="span" size="Inherit" truncate>
{t('Home.message_search')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavLink>
</NavItem>
</NavCategory>
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
{t('Home.rooms')}
</RoomNavCategoryButton>
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = sortedRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={vItem.index}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</PageNav>
);
}

View file

@ -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 <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
}
if (!room || !rooms.includes(room.roomId)) {
return (
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
);
}
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
</RoomProvider>
<JoinBeforeNavigate
roomIdOrAlias={roomIdOrAlias!}
eventId={eventId}
viaServers={viaServers}
/>
);
}

View file

@ -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<HTMLDivElement>(null);
const rooms = useHomeRooms();
const screenSize = useScreenSizeContext();
return (
<Page>
<PageHeader balance>
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box justifyContent="Center" alignItems="Center" gap="200">
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
<Text size="H3" truncate>
{t('Search.message_search')}
</Text>
</Box>
<Box grow="Yes" basis="No" />
</Box>
</PageHeader>
<Box style={{ position: 'relative' }} grow="Yes">
<Scroll ref={scrollRef} hideTrack visibility="Hover">
<PageContent>
<PageContentCenter>
<MessageSearch
defaultRoomsFilterName={t('Search.home')}
allowGlobal
rooms={rooms}
scrollRef={scrollRef}
/>
</PageContentCenter>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -1,3 +1 @@
export * from './Home';
export * from './Search';
export * from './RoomProvider';

View file

@ -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;
};

View file

@ -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}

View file

@ -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<HTMLDivElement, DirectMenuProps>(({ 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<RectCords>();

View file

@ -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<HTMLDivElement, HomeMenuProps>(({ 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 (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
aria-disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Home.mark_as_read')}
</Text>
</MenuItem>
</Box>
</Menu>
);
});
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<RectCords>();
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<HTMLButtonElement> = (evt) => {
evt.preventDefault();
const cords = evt.currentTarget.getBoundingClientRect();
setMenuAnchor((currentState) => {
if (currentState) return undefined;
return cords;
});
};
return (
<SidebarItem active={homeSelected}>
<SidebarItemTooltip tooltip={t('Home.home')}>
{(triggerRef) => (
<SidebarAvatar
as="button"
ref={triggerRef}
outlined
onClick={handleHomeClick}
onContextMenu={handleContextMenu}
>
<Icon src={Icons.Home} filled={homeSelected} />
</SidebarAvatar>
)}
</SidebarItemTooltip>
{homeUnread && (
<SidebarItemBadge hasCount={homeUnread.total > 0}>
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
</SidebarItemBadge>
)}
{menuAnchor && (
<PopOut
anchor={menuAnchor}
position="Right"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
</FocusTrap>
}
/>
)}
</SidebarItem>
);
}

View file

@ -1,4 +1,3 @@
export * from './HomeTab';
export * from './DirectTab';
export * from './SpaceTabs';
export * from './InboxTab';

View file

@ -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 (
<RoomProvider key={room.roomId} value={room}>
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
</RoomProvider>
);
}
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 (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
// 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 <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
}
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
@ -66,9 +76,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
);
}
return (
<RoomProvider key={room.roomId} value={room}>
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
</RoomProvider>
);
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
}

View file

@ -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 (

View file

@ -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<string>,
@ -147,6 +165,19 @@ export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<s
return useSelectedRooms(roomsAtom, selector);
};
// Universal-Direct-aware «every joined non-space room» (P3c). Mirrors
// `useRooms` but without the `m.direct` exclusion — used for surfaces that
// classify rooms by space-vs-room rather than DM-vs-room. Message search
// scope is the canonical consumer: after P3c every joined non-space room
// must be reachable from MessageSearch, including `m.direct`-tagged ones.
export const useNonSpaceRooms = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
const selector: RoomSelector = useCallback(
(roomId: string) => isRoom(mx.getRoom(roomId)),
[mx]
);
return useSelectedRooms(roomsAtom, selector);
};
export const useOrphanRooms = (
mx: MatrixClient,
roomsAtom: RoomsAtom,

View file

@ -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<string, unknown>;
delete orphan.messageLayout;
delete orphan.messageSpacing;
delete orphan.legacyUsernameColor;
merged.migrationsApplied = {
...(merged.migrationsApplied ?? {}),
[P3C_CLEANUP_KEY]: true,
};
setSettings(merged);
}
return merged;
};

View file

@ -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<string>
): 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 &&

View file

@ -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',