From 1c5ecb5309e9558fbbbd5f78eacc6e1367aa053b Mon Sep 17 00:00:00 2001 From: "v.lagerev" Date: Tue, 28 Apr 2026 00:54:53 +0300 Subject: [PATCH] redesign(p3a): land Stream message layout for DMs with rail, author dots, asymmetric bubbles, Stream day-divider, and sysline state events --- docs/ai/architecture.md | 19 +- public/locales/en.json | 4 +- public/locales/ru.json | 4 +- src/app/components/message/Reaction.css.ts | 3 +- src/app/components/message/Reply.css.ts | 14 +- src/app/components/message/Reply.tsx | 10 +- .../message/content/EventContent.tsx | 87 +++- src/app/components/message/layout/Stream.tsx | 168 +++++++ src/app/components/message/layout/index.ts | 1 + .../components/message/layout/layout.css.ts | 472 +++++++++++++++++- .../components/message/layout/streamDebug.ts | 221 ++++++++ .../components/unread-badge/UnreadBadge.tsx | 2 +- src/app/features/room/RoomTimeline.tsx | 349 +++++++++++-- src/app/features/room/message/Message.tsx | 135 +++-- src/app/features/settings/general/General.tsx | 81 +-- src/app/hooks/useDotColor.ts | 107 ++++ src/app/hooks/useIsDirectStream.ts | 21 + src/app/hooks/useMessageLayout.ts | 26 - src/app/hooks/useRoomNavigate.ts | 13 +- src/app/pages/client/direct/RoomProvider.tsx | 15 +- src/app/pages/client/home/RoomProvider.tsx | 16 +- src/app/utils/room.ts | 23 + 22 files changed, 1584 insertions(+), 207 deletions(-) create mode 100644 src/app/components/message/layout/Stream.tsx create mode 100644 src/app/components/message/layout/streamDebug.ts create mode 100644 src/app/hooks/useDotColor.ts create mode 100644 src/app/hooks/useIsDirectStream.ts delete mode 100644 src/app/hooks/useMessageLayout.ts diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index b7ad5c54..cf9afca5 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -66,19 +66,28 @@ Router in `Router.tsx`. Each top-level tab (`/home/`, `/direct/`, `/space/...`, → 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 use the `useIsDirectStream(room)` hook (introduced in DM redesign P3a) which combines three synchronous sources: `mDirectAtom.has(roomId)` (returning user — populated on first paint via `IndexedDBStore.startup()` await before `startClient()`) + `room.getDMInviter()` (matrix-js-sdk SDK helper for invite-state with `is_direct: true` in member content's `prev_content`) + `isDirectInvite(room, myUserId)` ([src/app/utils/room.ts:67](../../src/app/utils/room.ts), reads current member content for `is_direct: true`). 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. +`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: + +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()`. + +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. + +For the per-row Stream gate, prefer the `isStream` prop chain (RoomTimeline computes `useIsDirectStream(room)` once and threads it through `` / `` props) over calling the hook per row — this avoids subscribing every row to the `mDirectAtom` in non-DM rooms. Don't gate on `useIsDirectRoom()` alone — it misses the invited-user case. Bridged Telegram DMs that lack `is_direct: true` on invite are deferred to a future plan (separate Telegram tab/namespace). See `docs/plans/dm_1x1_redesign.md` §6.5 for the full rationale. ## 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 (Compact/Bubble/Modern) per `messageLayout` setting, renders edit/delete/react menu, mention/hashtag links, reactions viewer | +| `room/message/` | `Message.tsx` (~1335 LOC) — selects layout per the `isStream` prop (DM rooms → ``) or per the persisted `messageLayout` setting (non-DM → Compact/Bubble/Modern), renders edit/delete/react menu, mention/hashtag links, reactions viewer. The DM-stream gate is computed once in `RoomTimeline.tsx` via `useIsDirectStream(room)` and threaded as a prop so we don't subscribe every row to `mDirectAtom`. | | `room-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). General has the `MessageLayout` dropdown. **Logout lives here only.** | +| `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.** | | `lobby/` | Space/room lobby view | | `search/` | Global search | | `message-search/` | In-room message search | @@ -96,7 +105,7 @@ Router in `Router.tsx`. Each top-level tab (`/home/`, `/direct/`, `/space/...`, ## Key Components (`src/app/components/`) -- `message/` — Message rendering. Layout variants live in `message/layout/{Modern,Compact,Bubble}.tsx` + `layout.css.ts` recipes. Selected per `MessageLayout` enum in `state/settings.ts` (Modern=0, Compact=1, Bubble=2). `EventContent.tsx` has its own layout switch for membership/state events. +- `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). - `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve. - `emoji-board/` — Emoji picker @@ -140,7 +149,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 here. +- `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`. - `sessions.ts` — Active session - `upload.ts` — Upload progress (in-memory) - `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread` diff --git a/public/locales/en.json b/public/locales/en.json index 0bf537f6..cffe521f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -128,8 +128,8 @@ "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_layout": "Message Layout", "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", @@ -377,8 +377,8 @@ "segment_coming_soon": "Coming soon", "self_row_label": "You", "self_row_preview": "Settings & profile", + "message_me_label": "me", "status_e2ee": "e2ee", - "chats": "Chats", "username": "Username", "username_placeholder": "username", "server": "Server", diff --git a/public/locales/ru.json b/public/locales/ru.json index be007877..83500a6d 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -128,8 +128,8 @@ "hide_activity": "Скрыть набор текста и уведомления о прочтении", "hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.", "messages": "Сообщения", - "message_layout": "Макет сообщений", "message_spacing": "Интервал сообщений", + "message_spacing_dm_note": "В личных чатах используется фиксированный интервал, чтобы линия таймлайна оставалась непрерывной.", "legacy_username_color": "Классический цвет имени", "hide_membership": "Скрыть изменения участников", "hide_profile": "Скрыть изменения профиля", @@ -377,8 +377,8 @@ "segment_coming_soon": "Скоро", "self_row_label": "Я", "self_row_preview": "Настройки и профиль", + "message_me_label": "я", "status_e2ee": "e2ee", - "chats": "Чаты", "username": "Имя пользователя", "username_placeholder": "username", "server": "Сервер", diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..7ea1405a 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -20,7 +20,8 @@ export const Reaction = style([ padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`, backgroundColor: Container, border: `${config.borderWidth.B300} solid ${ContainerLine}`, - borderRadius: config.radii.R300, + // Pill chip — matches stream-v2-dawn.jsx canon line 100 (`borderRadius: 99`). + borderRadius: toRem(9999), selectors: { 'button&': { diff --git a/src/app/components/message/Reply.css.ts b/src/app/components/message/Reply.css.ts index 7e5540fd..6862206b 100644 --- a/src/app/components/message/Reply.css.ts +++ b/src/app/components/message/Reply.css.ts @@ -1,5 +1,5 @@ -import { style } from '@vanilla-extract/css'; -import { config, toRem } from 'folds'; +import { createVar, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; export const ReplyBend = style({ flexShrink: 0, @@ -18,11 +18,21 @@ export const ThreadIndicator = style({ }, }); +// Per-reply quote-bar tint — Reply.tsx sets `--reply-bar-color` inline from +// the reply target's user-hash colour (cssColorMXID). Falls back to the muted +// container line so unresolved replies still look like quotes, not text. +export const ReplyBarColor = createVar(); + export const Reply = style({ + vars: { + [ReplyBarColor]: color.SurfaceVariant.ContainerLine, + }, marginBottom: toRem(1), minWidth: 0, maxWidth: '100%', minHeight: config.lineHeight.T300, + paddingLeft: toRem(8), + borderLeft: `${toRem(2)} solid ${ReplyBarColor}`, selectors: { 'button&': { cursor: 'pointer', diff --git a/src/app/components/message/Reply.tsx b/src/app/components/message/Reply.tsx index 57bf2af9..536a0698 100644 --- a/src/app/components/message/Reply.tsx +++ b/src/app/components/message/Reply.tsx @@ -18,11 +18,19 @@ type ReplyLayoutProps = { username?: ReactNode; }; export const ReplyLayout = as<'div', ReplyLayoutProps>( - ({ username, userColor, className, children, ...props }, ref) => ( + ({ username, userColor, className, style, children, ...props }, ref) => ( diff --git a/src/app/components/message/content/EventContent.tsx b/src/app/components/message/content/EventContent.tsx index 130ba8c9..fcbc4acf 100644 --- a/src/app/components/message/content/EventContent.tsx +++ b/src/app/components/message/content/EventContent.tsx @@ -1,15 +1,96 @@ -import { Box, Icon, IconSrc } from 'folds'; -import React, { ReactNode } from 'react'; +import { Box, Icon, IconSrc, color } from 'folds'; +import React, { ReactNode, createContext, useContext, useRef } from 'react'; +import classNames from 'classnames'; import { BubbleLayout, CompactLayout, ModernLayout } from '..'; import { MessageLayout } from '../../../state/settings'; +import * as layoutCss from '../layout/layout.css'; +import { useStreamLayoutDebug } from '../layout/streamDebug'; +import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; + +// IsStreamProvider lets the Event wrapper (in features/room/message/Message.tsx) +// declare «this state event lives inside a Stream-layout DM». EventContent +// reads it and switches to a sysline render — a thin one-line system row, not +// a bubble. Plumbed through context so EventContent's API doesn't grow a +// `room` prop (it doesn't need the room itself, only the gate decision). +type StreamContext = { + enabled: boolean; + railStart?: boolean; + railEnd?: boolean; +}; + +const IsStreamContext = createContext({ enabled: false }); +export const IsStreamProvider = IsStreamContext.Provider; export type EventContentProps = { - messageLayout: number; + messageLayout: MessageLayout; time: ReactNode; iconSrc: IconSrc; content: ReactNode; }; export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) { + const { enabled: isStream, railStart, railEnd } = useContext(IsStreamContext); + const compact = useScreenSizeContext() === ScreenSize.Mobile; + const rootRef = useRef(null); + const timeRef = useRef(null); + const railRef = useRef(null); + const dotRef = useRef(null); + const bodyRef = useRef(null); + + useStreamLayoutDebug( + 'sysline', + { + root: rootRef, + timeColumn: timeRef, + rail: railRef, + dot: dotRef, + content: bodyRef, + }, + isStream + ); + + 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 ( +
+
+ {time} +
+ + + + + + +
{content}
+
+ {/* `messageLayout` is intentionally unused in stream-mode. */} +
+ ); + } + const beforeJSX = ( {messageLayout === MessageLayout.Compact && time} diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx new file mode 100644 index 00000000..bf99a278 --- /dev/null +++ b/src/app/components/message/layout/Stream.tsx @@ -0,0 +1,168 @@ +import React, { ReactNode, useImperativeHandle, useRef } from 'react'; +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 layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b). +// +// Visual structure: +// ┌──┬─────────────────────────────────────────────────┐ +// │ │ bubble (asymmetric radius) │ +// │● │ ┌──────────────────────────────────────────┐ │ +// │ │ │ time · sender · e2ee chip (header line) │ │ +// │ │ │ message body │ │ +// │ │ └──────────────────────────────────────────┘ │ +// └──┴─────────────────────────────────────────────────┘ +// rail+dot bubble (time absolutely-positioned to the +// LEFT of the bubble, on the rail-time column) +// +// Read-state lives entirely on the dot now (own = Primary violet, opacity +// 0.3 → 1.0 when peer reads; incoming = author hash, opacity 1.0 → 0.3 once +// I read it). The legacy WhatsApp checkmark `` is intentionally +// not rendered in Stream — the dot already encodes that signal. +// +// Geometry constants live in layout.css.ts (StreamRailWidth, StreamDotSize). +// `time` and `header` are caller-controlled ReactNodes so Message.tsx keeps +// ownership of the timestamp formatting / sender Username component / e2ee +// indicators it already builds. + +export type StreamLayoutProps = { + time?: ReactNode; + dotColor: string; + dotOpacity: number; + isOwn?: boolean; + compact?: boolean; + header?: ReactNode; + railStart?: boolean; + railEnd?: boolean; +}; + +// Stream day divider — used by RoomTimeline.tsx in place of the legacy +// TimelineDivider for DM rooms. Sits as a regular row in the timeline so the +// rail flows through it: rail-column has the date label (small mono uppercase), +// a slightly larger Fleet-soft dot anchors on the rail, and the rest of the +// row is a faint hairline. Matches stream-v2-dawn.jsx::DawnPhoneV3 line 73-78. +export type StreamDayDividerProps = { + label: ReactNode; +}; + +export const StreamDayDivider = as<'div', StreamDayDividerProps>( + ({ className, label, ...props }, ref) => { + // Reads screen size internally instead of taking a prop so RoomTimeline + // doesn't have to thread it through. The day-divider must use the SAME + // `compact` value as message rows above and below or the rail and label + // would mis-align horizontally on desktop. + const compact = useScreenSizeContext() === ScreenSize.Mobile; + return ( +
+ {/* Rail segment under the row — same recipe as message rows so the + rail flows continuously through the day boundary. */} + +
{label}
+ +
+
+ ); + } +); + +export const StreamLayout = as<'div', StreamLayoutProps>( + ( + { + className, + time, + dotColor, + dotOpacity, + isOwn, + compact, + header, + railStart, + railEnd, + children, + ...props + }, + ref + ) => { + const rootRef = useRef(null); + const timeRef = useRef(null); + const railRef = useRef(null); + const dotRef = useRef(null); + const bubbleRef = useRef(null); + const headerRef = useRef(null); + + 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. + useStreamLayoutDebug( + 'message', + { + root: rootRef, + timeColumn: timeRef, + rail: railRef, + dot: dotRef, + content: bubbleRef, + header: headerRef, + }, + true + ); + + return ( +
+ +
+ + {time} + + + + + {header && ( +
+ {header} +
+ )} + {children} +
+
+ ); + } +); diff --git a/src/app/components/message/layout/index.ts b/src/app/components/message/layout/index.ts index 979f5800..8d394089 100644 --- a/src/app/components/message/layout/index.ts +++ b/src/app/components/message/layout/index.ts @@ -1,4 +1,5 @@ export * from './Modern'; export * from './Compact'; export * from './Bubble'; +export * from './Stream'; export * from './Base'; diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index cc2cd0c6..a6a7d05c 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -1,4 +1,4 @@ -import { createVar, keyframes, style, styleVariants } from '@vanilla-extract/css'; +import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css'; import { recipe, RecipeVariants } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; @@ -179,6 +179,476 @@ export const UsernameBold = style({ fontWeight: 550, }); +// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b). +// Geometry follows stream-v2-dawn.jsx::DawnPhoneV3 (rail 60) and DawnDesktopV3 +// (rail 84). We use a single 64px rail-column as a pragmatic middle: tested +// readable on mobile (>=320px) and not overweight on desktop. Tunable via +// runbook smoke. +const StreamRailWidth = toRem(64); +// Single X-axis source of truth: every rail line/dot center derives from this. +// Dots subtract half their own size; the 1px rail subtracts half its width. +const StreamRailCenterX = StreamRailWidth; +const StreamDotSize = toRem(9); +// Sysline dots are smaller than message dots so system breadcrumbs read +// lighter than ordinary message rows on the same rail. +const StreamSyslineDotSize = toRem(6); +// 1.1× the message-row dot (9px → 9.9px would be visually indistinguishable), +// rounded up so the day marker reads clearly larger than ordinary dots while +// staying on the rail-center geometry. Was 11px; bumped per design feedback. +const StreamDayDotSize = toRem(12.1); +const StreamRailLineWidth = '1px'; +const StreamBubbleBorderWidth = '1px'; +const StreamTimeLineHeight = toRem(13); +const StreamRailBridgeY = config.space.S400; +// Vertical center of the message dot from the row's top edge — must equal the +// vertical center of the StreamBubbleHeader text line so timestamp / dot / +// sender-nickname share one baseline: +// = StreamRoot.paddingTop (S100) +// + bubble.borderTop (1px) +// + bubble.paddingTop (S200) — the header sits inside the bubble's content +// box, AFTER the padding +// + half of the header line (T200 / 2) +// The dot and timestamp are absolute-positioned children of StreamBubble (its +// padding-box is their containing block), so their CSS `top` must include the +// S200 paddingTop offset to land on the same baseline as the header text. +const StreamMessageDotCenterY = `calc(${config.space.S100} + ${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`; +const StreamMessageRailEndHeight = `calc(${StreamRailBridgeY} + ${StreamMessageDotCenterY})`; +const StreamRailLineLeft = `calc(${StreamRailCenterX} - (${StreamRailLineWidth} / 2))`; +const StreamDotLeft = `calc(${StreamRailCenterX} - (${StreamDotSize} / 2))`; +const StreamDayDotLeft = `calc(${StreamRailCenterX} - (${StreamDayDotSize} / 2))`; +const StreamBubbleColumnStartX = `calc(${StreamRailCenterX} + ${config.space.S500})`; +// Padding-box-left of the bubble from the row's left edge. CSS positions abs +// children of `position: relative` parents against the parent's padding box +// (i.e. inside the border, outside the padding) — so we must NOT include +// paddingLeft (S300) here, even though the visible bubble content begins +// further in. Earlier revisions added `+ S300` and offset every header item +// by 12 px to the left of where the rail expected them. +const StreamBubblePaddingBoxLeftX = `calc(${StreamBubbleColumnStartX} + ${StreamBubbleBorderWidth})`; +const StreamHeaderRailCenterX = `calc(${StreamRailCenterX} - (${StreamBubblePaddingBoxLeftX}))`; + +// Rail-to-bubble gap. Used as the grid `columnGap` on StreamRoot / +// StreamDayRoot so the bubble's left edge sits at rail.center + +// StreamRailGutter. +const StreamRailGutter = config.space.S500; + +// Rail-to-timestamp gap. Distance between the visible text-right-edge of +// "10:30 PM" / day labels and the rail-line center. Tuned by eye against +// the bubble-side gap (StreamRailGutter = 20 px) — the timestamp reads +// closer to the dot than the bubble does, because text rights are sparse +// glyphs while the bubble has a solid border. 14 px is roughly half of +// what the symmetric 28 px construction looked like, picked from a smoke +// session — adjust with confidence, the geometry around it is robust to +// any reasonable value (see StreamTimeBoxWidth). +const StreamTimeRailGap = toRem(14); + +// Width buffer for timestamp / day-label elements. The rail-time column is +// visually 64 px wide (= StreamRailWidth), but constraining the timestamp +// element to exactly that width breaks the `paddingRight: StreamTimeRailGap` +// shift knob: "10:30 PM" in JetBrains Mono Variable 11px is ≈ 53 px, so as +// soon as paddingRight pushes content-box below ~53 px the text overflows +// and Chromium stops shifting the rendered right edge with paddingRight. +// Giving the element a width well above the longest expected timestamp +// keeps content-box > text-width at every reasonable paddingRight, so +// (element_right − paddingRight) becomes a linear text-shift control. +// Element right edge stays anchored at rail.center via `left:` calc / +// `justify-self: end` (grid items); it overflows LEFT into row margin / +// page-nav area where there's empty space anyway. +const StreamTimeBoxWidth = toRem(140); + +// 1cm desktop nudge — pushes the entire row block (rail + dot + bubble) +// right so the visible content sits comfortably away from the PageNav. +// Implemented as `marginLeft` on the row root so EVERY child (grid items +// AND absolute rail / dot spans) shifts together — abs-positioned children +// resolve their containing block from the row root's padding box, which +// moves with the row root, no per-child calc needed. +const StreamRowDesktopOffset = toRem(38); + +export const StreamRoot = recipe({ + base: { + position: 'relative', + display: 'grid', + gridTemplateColumns: `${StreamRailWidth} 1fr`, + alignItems: 'flex-start', + columnGap: StreamRailGutter, + paddingTop: config.space.S100, + paddingBottom: config.space.S100, + paddingRight: config.space.S400, + }, + variants: { + compact: { + // Desktop: shift the whole row right via marginLeft. abs rail / dot + // children move with the row automatically; no per-child compensation. + false: { + marginLeft: StreamRowDesktopOffset, + }, + // Mobile: no left shift, and stretch the bubble further to the right + // edge by zeroing StreamRoot's right padding. + true: { + paddingRight: 0, + }, + }, + }, + defaultVariants: { compact: false }, +}); + +export const StreamTimeColumn = style({ + textAlign: 'right', + // Right anchor at `rail.center − StreamTimeRailGap`. Slightly larger than + // StreamRailGutter (the rail↔bubble gap) so the PERCEIVED text-edge↔dot + // distance matches the dot↔bubble distance — see StreamTimeRailGap comment. + paddingRight: StreamTimeRailGap, + // Width-buffer + right-anchor on the rail-time column. Grid track 1 is + // 64 px (= StreamRailWidth); element width 140 px overflows LEFT, with + // justifySelf: end pinning the element right edge to track 1's right edge + // (= rail.center). See StreamTimeBoxWidth comment for why this buffer is + // required for paddingRight to act as a working text-shift knob. + width: StreamTimeBoxWidth, + justifySelf: 'end', + paddingTop: 0, + fontSize: toRem(11), + fontVariantNumeric: 'tabular-nums', + color: color.Surface.OnContainer, + opacity: 0.55, + whiteSpace: 'nowrap', + fontFamily: + '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', +}); + +globalStyle(`${StreamTimeColumn} time`, { + display: 'block', + fontFamily: 'inherit', + fontSize: toRem(11), + lineHeight: StreamTimeLineHeight, +}); + +// Per-message rail segment. Rendering the rail at the timeline level would +// require touching RoomTimeline.tsx (deferred to P3b). The negative top/bottom +// offsets bridge MessageBase's marginTop (default messageSpacing S400 = 1rem) +// so consecutive Stream rows read as one continuous line, not a stack of +// disconnected segments. Each row's rail extends 1rem above + 1rem below its +// own StreamRoot. The line colour is opaque (not alpha via `opacity`) so any +// small segment overlap cannot compound into darker grey patches. +export const StreamRail = style({ + position: 'absolute', + top: `calc(-1 * ${StreamRailBridgeY})`, + bottom: `calc(-1 * ${StreamRailBridgeY})`, + // Positioned relative to the row root's padding box. The desktop offset is + // applied as marginLeft on the row root, so the row root itself shifts and + // the rail's `left: 63.5` follows automatically — no per-child calc needed. + left: StreamRailLineLeft, + width: StreamRailLineWidth, + background: color.Surface.ContainerLine, + pointerEvents: 'none', + zIndex: 0, +}); + +export const StreamRailEnd = style({ + bottom: 'auto', + height: StreamMessageRailEndHeight, +}); + +export const StreamRailStart = style({ + top: StreamMessageDotCenterY, +}); + +export const StreamRailSingle = style({ + display: 'none', +}); + +// Dot is a two-layer span: outer Halo paints a solid bg-coloured disk + ring +// that fully masks the rail behind the dot (regardless of opacity); inner Fill +// carries the actual author colour and the read-state opacity. Without this +// split, opacity < 1.0 on the dot would let the rail line show through. +export const StreamDotHalo = style({ + position: 'absolute', + width: StreamDotSize, + height: StreamDotSize, + borderRadius: '50%', + // Same as StreamRail — positioned in the row root's padding box, which + // already carries the desktop marginLeft shift, so just `StreamDotLeft`. + left: StreamDotLeft, + background: color.Surface.Container, + boxShadow: `0 0 0 3px ${color.Surface.Container}`, + pointerEvents: 'none', + flexShrink: 0, + zIndex: 2, +}); + +export const StreamSyslineDotHalo = style({ + width: StreamSyslineDotSize, + height: StreamSyslineDotSize, + // Smaller sysline dot — re-centered on the rail. No row-offset compensation + // needed because the row root's marginLeft shifts the abs-children's + // containing block too. + left: `calc(${StreamRailCenterX} - (${StreamSyslineDotSize} / 2))`, + top: '50%', + transform: 'translateY(-50%)', +}); + +export const StreamSyslineRailEnd = style({ + bottom: 'auto', + height: `calc(${StreamRailBridgeY} + 50%)`, +}); + +export const StreamSyslineRailStart = style({ + top: '50%', +}); + +// `top` here is measured from the bubble's padding-box-top (CSS abs-positioning +// rule). The header text starts AFTER bubble's S200 paddingTop, so we add +// S200 + T200/2 to land the dot's vertical centre on the header text baseline. +// Without S200 the dot would float 8px ABOVE the nickname / timestamp line. +export const StreamHeaderDotHalo = style({ + top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`, + left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`, + transform: 'translateY(-50%)', +}); + +export const StreamDotFill = style({ + position: 'absolute', + inset: 0, + borderRadius: '50%', + pointerEvents: 'none', + // Smooth read↔unread transition (peer reads my message → dot fades up to + // full vibrant, I read an incoming message → dot fades down to grey). + // Without this the state flip is a hard step. + transition: 'opacity 220ms ease, filter 220ms ease', +}); + +export const StreamBubble = recipe({ + base: { + backgroundColor: color.SurfaceVariant.Container, + color: color.SurfaceVariant.OnContainer, + border: `${StreamBubbleBorderWidth} solid ${color.SurfaceVariant.ContainerLine}`, + paddingTop: config.space.S200, + paddingBottom: config.space.S200, + minWidth: 0, + maxWidth: toRem(720), + position: 'relative', + zIndex: 1, + gridColumn: 2, + }, + variants: { + own: { + true: { + // Asymmetric radius — own messages: top-left flat (4px), three other + // corners 12px. Matches stream-v2-dawn.jsx canon line 95 / 271 + // (`borderRadius: '4px 12px 12px 12px'`). R500 = 0.75rem = 12px. + borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`, + }, + false: { + // Incoming: top-right flat, three other corners 12px (mirrored). + borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`, + }, + }, + // Mobile (≤750px): bubble fills the full message column, matching + // stream-v2-dawn.jsx::DawnPhoneV3 line 92-101 where the bubble is a plain + // block element inside the flex content column. Desktop (>750px): bubble + // sizes to its content (canon DawnDesktopV3 line 269-272 sets + // `display: inline-block`), so short messages don't stretch to the column + // edge. Branched in JSX via useScreenSizeContext (no media queries — see + // docs/plans/dm_1x1_redesign.md §5.5). + // + // Horizontal padding is split per variant: mobile keeps the canon-tight + // S300 (=12 px) on each side; desktop bumps to ~15 px (≈+25 % padding, + // visible bubble ~5 % wider for the same content) so messages feel less + // cramped on a wide viewport. + compact: { + true: { + display: 'block', + width: '100%', + paddingLeft: config.space.S300, + paddingRight: config.space.S300, + }, + false: { + display: 'inline-block', + width: 'fit-content', + maxWidth: '100%', + paddingLeft: toRem(15), + paddingRight: toRem(15), + }, + }, + }, + defaultVariants: { + own: false, + compact: false, + }, +}); + +export const StreamBubbleHeader = style({ + position: 'relative', + marginBottom: toRem(2), + fontSize: toRem(11), + lineHeight: config.lineHeight.T200, + minHeight: config.lineHeight.T200, + fontWeight: 600, + display: 'flex', + alignItems: 'center', + gap: toRem(6), + flexWrap: 'nowrap', +}); + +export const StreamHeaderTime = style({ + position: 'absolute', + // Match StreamHeaderDotHalo vertical anchor — see comment there. Timestamp, + // dot, and nickname must share one baseline, so all three derive from + // (bubble.paddingTop + T200 / 2) inside the bubble's padding box. + top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`, + // Element right edge anchored at rail.center inside the bubble's padding + // box (StreamHeaderRailCenterX is rail.center expressed in that + // coordinate system). Width is the 140 px buffer (see StreamTimeBoxWidth + // comment), so the element overflows LEFT — that's intentional, the + // visual rail-time column is the right 64 px slice of this element. + left: `calc(${StreamHeaderRailCenterX} - ${StreamTimeBoxWidth})`, + width: StreamTimeBoxWidth, + // Time-side gap is StreamTimeRailGap, slightly wider than StreamRailGutter + // (the bubble-side gap) so the perceived spacing reads equal — see + // StreamTimeRailGap comment. + paddingRight: StreamTimeRailGap, + transform: 'translateY(-50%)', + boxSizing: 'border-box', + textAlign: 'right', + fontSize: toRem(11), + lineHeight: StreamTimeLineHeight, + fontVariantNumeric: 'tabular-nums', + color: color.Surface.OnContainer, + opacity: 0.55, + whiteSpace: 'nowrap', + fontFamily: + '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', +}); + +globalStyle(`${StreamHeaderTime} time`, { + display: 'block', + fontFamily: 'inherit', + fontSize: toRem(11), + lineHeight: StreamTimeLineHeight, +}); + +// Sysline — thin one-line state-event row inside Stream layout. Same rail +// geometry as message rows so dots align, but no bubble: just iconSrc + body +// in faint mono so membership / topic / pin events read as system breadcrumbs. +export const StreamSysline = style({ + paddingTop: toRem(2), + paddingBottom: toRem(2), +}); + +export const StreamSyslineTimeColumn = style({ + alignSelf: 'center', +}); + +export const StreamSyslineBody = style({ + fontSize: toRem(11.5), + color: color.Surface.OnContainer, + opacity: 0.55, + fontFamily: + '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + minWidth: 0, +}); + +// Day divider as a Stream row — replaces the old TimelineDivider/Badge for DM +// rooms so the rail reads continuous through day boundaries. Matches +// stream-v2-dawn.jsx::DawnPhoneV3 line 73-78 (date label in rail-time column, +// slightly larger Fleet-violet dot ON the rail, faint horizontal divider line +// reaching out to the right of the dot). +// +// Implemented on the same grid as message rows so the day-dot shares the exact +// StreamRailCenterX with message/sysline dots. The day-row also paints its OWN rail segment via StreamRail +// (same recipe used by message rows) so the rail visually flows through the +// day-divider — without this the previous and next message-row rails leave a +// gap of `paddingTop + dotHeight + paddingBottom` (≈ 27px) here. +export const StreamDayRoot = recipe({ + base: { + display: 'grid', + gridTemplateColumns: `${StreamRailWidth} 1fr`, + alignItems: 'center', + columnGap: StreamRailGutter, + paddingTop: toRem(10), + paddingBottom: toRem(10), + paddingRight: config.space.S400, + position: 'relative', + }, + variants: { + // Day-divider row mirrors the StreamRoot horizontal offset so the rail + // and date label stay flush with the message rows above and below. + // Uses marginLeft (not paddingLeft) for the same reason as StreamRoot — + // abs-positioned dot/line children inherit the shift automatically. + compact: { + false: { + marginLeft: StreamRowDesktopOffset, + }, + true: { + paddingRight: 0, + }, + }, + }, + defaultVariants: { compact: false }, +}); + +export const StreamDayLabel = style({ + textAlign: 'right', + // Day label sits on the same time-side gap as message timestamps so all + // text on the rail-time column shares one right anchor. + paddingRight: StreamTimeRailGap, + // Same width-buffer + right-anchor pattern as StreamTimeColumn, so + // paddingRight reliably shifts the day label too. + width: StreamTimeBoxWidth, + justifySelf: 'end', + fontSize: toRem(12), + textTransform: 'uppercase', + letterSpacing: toRem(1), + fontWeight: 600, + color: color.Surface.OnContainer, + opacity: 0.55, + boxSizing: 'border-box', + // Russian "СЕГОДНЯ" / "ВЧЕРА" / full date strings can be wider than the + // 64px rail-time-column. Allow the label to overflow LEFT (text-align: right + // keeps the right edge anchored at the rail-gap), since there is empty + // marginLeft / paddingLeft space to bleed into. nowrap prevents wrapping + // to a second line which would break day-row geometry. + whiteSpace: 'nowrap', + overflow: 'visible', + fontFamily: + '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', +}); + +// Slightly larger dot than message-row dots, painted in Fleet violet — canon +// uses fleetSoft (#a59cff) for day markers. We use Primary.MainHover (the +// same soft violet from the Dawn palette) so the divider stands out against +// ordinary author dots without breaking the rail rhythm. +export const StreamDayDot = style({ + position: 'absolute', + top: '50%', + // Row root's marginLeft already shifts the containing block on desktop — + // no per-child compensation needed. + left: StreamDayDotLeft, + width: StreamDayDotSize, + height: StreamDayDotSize, + borderRadius: '50%', + background: color.Primary.MainHover, + boxShadow: `0 0 0 4px ${color.Surface.Container}`, + pointerEvents: 'none', + transform: 'translateY(-50%)', + zIndex: 2, +}); + +// Hairline that fills the rest of the row to the right of the dot. Starts at +// the rail center; the right anchor stays unchanged. Row root's marginLeft +// applies the desktop shift through the containing block. +export const StreamDayLine = style({ + position: 'absolute', + top: '50%', + left: StreamRailCenterX, + right: config.space.S400, + height: 1, + background: color.Surface.ContainerLine, + transform: 'translateY(-50%)', + zIndex: 0, +}); + export const MessageTextBody = recipe({ base: { wordBreak: 'break-word', diff --git a/src/app/components/message/layout/streamDebug.ts b/src/app/components/message/layout/streamDebug.ts new file mode 100644 index 00000000..1d7f0c92 --- /dev/null +++ b/src/app/components/message/layout/streamDebug.ts @@ -0,0 +1,221 @@ +import { RefObject, useLayoutEffect } from 'react'; + +const STREAM_DEBUG_ENABLE_KEY = 'vojo_stream_debug'; +const STREAM_DEBUG_LIMIT = 120; + +type StreamDebugRefs = { + root: RefObject; + timeColumn: RefObject; + rail: RefObject; + dot: RefObject; + content?: RefObject; + header?: RefObject; +}; + +type StreamDebugKind = 'message' | 'sysline'; + +export type UseStreamLayoutDebug = ( + kind: StreamDebugKind, + refs: StreamDebugRefs, + active?: boolean +) => void; + +// Vite folds `import.meta.env.DEV` to a literal at build time, so the heavy +// dev-only implementation below is dropped from production bundles via dead- +// code elimination — only the no-op assignment ships. Opt-in at runtime via +// `localStorage.setItem('vojo_stream_debug', '1')`. +const noopUseStreamLayoutDebug: UseStreamLayoutDebug = () => undefined; + +let exportedHook: UseStreamLayoutDebug = noopUseStreamLayoutDebug; + +if (import.meta.env.DEV) { + const round = (value: number) => Math.round(value * 100) / 100; + const centerY = (rect: DOMRect) => rect.top + rect.height / 2; + + // Dump every CSS custom property visible on the element so we can confirm + // whether vanilla-extract's `vars: {}` overrides are reaching the timestamp + // element. vanilla-extract hashes var names, so we iterate everything + // starting with `--` rather than guessing the exact identifier. + const collectCssVars = (style: CSSStyleDeclaration) => { + const vars: Record = {}; + for (let i = 0; i < style.length; i += 1) { + const prop = style[i]; + if (prop.startsWith('--')) { + vars[prop] = style.getPropertyValue(prop).trim(); + } + } + return vars; + }; + + const inspectElement = (element: HTMLElement | null) => { + if (!element) return undefined; + const rect = element.getBoundingClientRect(); + const style = window.getComputedStyle(element); + + return { + top: round(rect.top), + right: round(rect.right), + bottom: round(rect.bottom), + left: round(rect.left), + width: round(rect.width), + height: round(rect.height), + centerY: round(centerY(rect)), + display: style.display, + paddingTop: style.paddingTop, + paddingRight: style.paddingRight, + paddingBottom: style.paddingBottom, + paddingLeft: style.paddingLeft, + marginTop: style.marginTop, + marginRight: style.marginRight, + marginBottom: style.marginBottom, + marginLeft: style.marginLeft, + lineHeight: style.lineHeight, + fontSize: style.fontSize, + opacity: style.opacity, + transform: style.transform, + cssVars: collectCssVars(style), + }; + }; + + type StreamDebugEntry = { + label: string; + kind: StreamDebugKind; + eventId?: string; + eventItem?: string; + timeText: string; + deltas: { + timeCenterMinusDotCenter?: number; + contentTopMinusDotCenter?: number; + headerCenterMinusDotCenter?: number; + railTopMinusDotCenter?: number; + railBottomMinusDotCenter?: number; + }; + root?: ReturnType; + timeColumn?: ReturnType; + time?: ReturnType; + dot?: ReturnType; + rail?: ReturnType; + content?: ReturnType; + header?: ReturnType; + rootOffset?: { + timeTopFromRoot?: number; + dotTopFromRoot?: number; + contentTopFromRoot?: number; + headerTopFromRoot?: number; + }; + }; + + type DebugWindow = Window & { + __VOJO_STREAM_DEBUG__?: StreamDebugEntry[]; + vojoStreamDebugDump?: () => StreamDebugEntry[]; + }; + + const getEnabled = () => + typeof window !== 'undefined' && + window.localStorage.getItem(STREAM_DEBUG_ENABLE_KEY) === '1'; + + const pushEntry = (entry: StreamDebugEntry) => { + const w = window as DebugWindow; + w.__VOJO_STREAM_DEBUG__ = [...(w.__VOJO_STREAM_DEBUG__ ?? []), entry].slice( + -STREAM_DEBUG_LIMIT + ); + w.vojoStreamDebugDump = () => w.__VOJO_STREAM_DEBUG__ ?? []; + }; + + const useStreamLayoutDebugDev: UseStreamLayoutDebug = (kind, refs, active = true) => { + const { root, timeColumn, rail, dot, content, header } = refs; + + useLayoutEffect(() => { + if (!active) return undefined; + if (!getEnabled()) return undefined; + + const frame = window.requestAnimationFrame(() => { + const rootElement = root.current; + const timeColumnElement = timeColumn.current; + const time = timeColumnElement?.querySelector('time') as HTMLElement | null; + const dotElement = dot.current; + const railElement = rail.current; + const contentElement = content?.current; + const headerElement = header?.current; + + const rootRect = rootElement?.getBoundingClientRect(); + const timeRect = time?.getBoundingClientRect(); + const dotRect = dotElement?.getBoundingClientRect(); + const contentRect = contentElement?.getBoundingClientRect(); + const headerRect = headerElement?.getBoundingClientRect(); + + const eventId = rootElement + ?.closest('[data-message-id]') + ?.getAttribute('data-message-id'); + const eventItem = rootElement + ?.closest('[data-message-item]') + ?.getAttribute('data-message-item'); + const timeCenter = timeRect ? centerY(timeRect) : undefined; + const dotCenter = dotRect ? centerY(dotRect) : undefined; + const label = `[vojo stream] ${kind} item=${eventItem ?? '?'} event=${eventId ?? '?'} time="${ + time?.textContent?.trim() ?? '' + }"`; + const entry: StreamDebugEntry = { + label, + kind, + eventId: eventId ?? undefined, + eventItem: eventItem ?? undefined, + timeText: time?.textContent?.trim() ?? '', + deltas: { + timeCenterMinusDotCenter: + timeCenter !== undefined && dotCenter !== undefined + ? round(timeCenter - dotCenter) + : undefined, + contentTopMinusDotCenter: + contentRect && dotCenter !== undefined + ? round(contentRect.top - dotCenter) + : undefined, + headerCenterMinusDotCenter: + headerRect && dotCenter !== undefined + ? round(centerY(headerRect) - dotCenter) + : undefined, + railTopMinusDotCenter: + railElement && dotCenter !== undefined + ? round(railElement.getBoundingClientRect().top - dotCenter) + : undefined, + railBottomMinusDotCenter: + railElement && dotCenter !== undefined + ? round(railElement.getBoundingClientRect().bottom - dotCenter) + : undefined, + }, + root: inspectElement(rootElement ?? null), + timeColumn: inspectElement(timeColumnElement ?? null), + time: inspectElement(time), + dot: inspectElement(dotElement), + rail: inspectElement(railElement), + content: inspectElement(contentElement ?? null), + header: inspectElement(headerElement ?? null), + rootOffset: rootRect + ? { + timeTopFromRoot: timeRect ? round(timeRect.top - rootRect.top) : undefined, + dotTopFromRoot: dotRect ? round(dotRect.top - rootRect.top) : undefined, + contentTopFromRoot: contentRect + ? round(contentRect.top - rootRect.top) + : undefined, + headerTopFromRoot: headerRect ? round(headerRect.top - rootRect.top) : undefined, + } + : undefined, + }; + + pushEntry(entry); + // eslint-disable-next-line no-console + console.groupCollapsed(label); + // eslint-disable-next-line no-console + console.log(entry); + // eslint-disable-next-line no-console + console.groupEnd(); + }); + + return () => window.cancelAnimationFrame(frame); + }, [active, kind, root, timeColumn, rail, dot, content, header]); + }; + + exportedHook = useStreamLayoutDebugDev; +} + +export const useStreamLayoutDebug: UseStreamLayoutDebug = exportedHook; diff --git a/src/app/components/unread-badge/UnreadBadge.tsx b/src/app/components/unread-badge/UnreadBadge.tsx index f8bfefa3..7867b0bd 100644 --- a/src/app/components/unread-badge/UnreadBadge.tsx +++ b/src/app/components/unread-badge/UnreadBadge.tsx @@ -20,7 +20,7 @@ export function UnreadBadgeCenter({ children }: { children: ReactNode }) { export function UnreadBadge({ highlight, count }: UnreadBadgeProps) { return ( 0 ? '400' : '200'} fill="Solid" radii="Pill" diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index eeb6b58a..92bf9448 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -65,6 +65,8 @@ import { MSticker, ImageContent, EventContent, + STREAM_MESSAGE_SPACING, + StreamDayDivider, } from '../../components/message'; import { factoryRenderLinkifyWithMention, @@ -119,6 +121,7 @@ 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 { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -439,6 +442,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli 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); const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents'); const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents'); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); @@ -608,11 +616,24 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli room, 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. + const isOwnLiveStreamMessage = + isStream && + mEvt.getSender() === mx.getUserId() && + !reactionOrEditEvent(mEvt) && + (mEvt.getType() === MessageEvent.RoomMessage || + mEvt.getType() === MessageEvent.RoomMessageEncrypted || + mEvt.getType() === MessageEvent.Sticker); + // if user is at bottom of timeline // keep paginating timeline and conditionally mark as read // otherwise we update timeline without paginating // so timeline can be updated with evt like: edits, reactions etc - if (atBottomRef.current) { + if (atBottomRef.current || isOwnLiveStreamMessage) { if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) { // Check if the document is in focus (user is actively viewing the app), // and either there are no unread messages or the latest message is from the current user. @@ -626,14 +647,27 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli scrollToBottomRef.current.count += 1; scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId(); + if (isOwnLiveStreamMessage) { + setAtBottom(true); + if (!atLiveEndRef.current) { + // Sending while viewing an older event intentionally jumps the DM back + // to live; clear stale deep-link focus before replacing the timeline. + setFocusItem(undefined); + } + } - setTimeline((ct) => ({ - ...ct, - range: { - start: ct.range.start + 1, - end: ct.range.end + 1, - }, - })); + setTimeline((ct) => { + if (!atLiveEndRef.current && isOwnLiveStreamMessage) { + return getInitialTimeline(room); + } + return { + ...ct, + range: { + start: ct.range.start + 1, + end: ct.range.end + 1, + }, + }; + }); return; } setTimeline((ct) => ({ ...ct })); @@ -641,7 +675,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, unreadInfo, hideActivity] + [mx, room, unreadInfo, hideActivity, isStream] ) ); @@ -1039,7 +1073,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const { t } = useTranslation(); const renderMatrixEvent = useMatrixEventRenderer< - [string, MatrixEvent, number, EventTimelineSet, boolean] + [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean] >( { // Suppress DM-call service events from the timeline. In encrypted @@ -1049,7 +1083,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // when MSC4075/MSC4310 stabilize. 'org.matrix.msc4075.rtc.notification': () => null, 'org.matrix.msc4310.rtc.decline': () => null, - [MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { + [MessageEvent.RoomMessage]: ( + mEventId, + mEvent, + item, + timelineSet, + collapse, + streamRailStart, + streamRailEnd + ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; @@ -1119,6 +1161,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} + streamRailStart={streamRailStart} + streamRailEnd={streamRailEnd} + isStream={isStream} > {mEvent.isRedacted() ? ( @@ -1139,7 +1184,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, - [MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => { + [MessageEvent.RoomMessageEncrypted]: ( + mEventId, + mEvent, + item, + timelineSet, + collapse, + streamRailStart, + streamRailEnd + ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; @@ -1213,6 +1266,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} + streamRailStart={streamRailStart} + streamRailEnd={streamRailEnd} + isStream={isStream} > {(() => { if (mEvent.isRedacted()) return ; @@ -1274,7 +1330,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, - [MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => { + [MessageEvent.Sticker]: ( + mEventId, + mEvent, + item, + timelineSet, + collapse, + streamRailStart, + streamRailEnd + ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const hasReactions = reactions && reactions.length > 0; @@ -1319,6 +1383,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli legacyUsernameColor={legacyUsernameColor || direct} hour24Clock={hour24Clock} dateFormatString={dateFormatString} + streamRailStart={streamRailStart} + streamRailEnd={streamRailEnd} + isStream={isStream} > {mEvent.isRedacted() ? ( @@ -1338,18 +1405,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ); }, - [StateEvent.RoomMember]: (mEventId, mEvent, item) => { + [StateEvent.RoomMember]: ( + mEventId, + mEvent, + item, + _timelineSet, + _collapse, + streamRailStart, + streamRailEnd + ) => { const membershipChanged = isMembershipChanged(mEvent); if (membershipChanged && hideMembershipEvents) return null; if (!membershipChanged && hideNickAvatarEvents) return null; const highlighted = focusItem?.index === item && focusItem.highlight; const parsed = parseMemberEvent(mEvent); + const iconSrc = + isStream && parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon; const timeJSX = (
)} - {messageLayout === MessageLayout.Compact && ( - + {isStream ? ( + + } + dotColor={dot.color} + dotOpacity={dot.opacity} + isOwn={isOwnMessage} + compact={isMobile} + railStart={streamRailStart} + railEnd={streamRailEnd} + header={ + // Stream rows always expose the author line: it gives every dot a + // stable visual anchor and keeps grouped messages readable. + + + + {isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} + + + + } + onContextMenu={handleContextMenu} + > {msgContentJSX} - - )} - {isBubble && isOwnMessage && ( - - - {msgContentJSX} - - {bubbleMetaJSX} - - )} - {isBubble && !isOwnMessage && ( - - {msgContentJSX} - - )} - {messageLayout !== MessageLayout.Compact && !isBubble && ( - - {headerJSX} - {msgContentJSX} - +
+ ) : ( + <> + {messageLayout === MessageLayout.Compact && ( + + {msgContentJSX} + + )} + {isBubble && isOwnMessage && ( + + + {msgContentJSX} + + {bubbleMetaJSX} + + )} + {isBubble && !isOwnMessage && ( + + {msgContentJSX} + + )} + {messageLayout !== MessageLayout.Compact && !isBubble && ( + + {headerJSX} + {msgContentJSX} + + )} + )} ); @@ -1191,6 +1250,9 @@ export type EventProps = { messageSpacing: MessageSpacing; hideReadReceipts?: boolean; showDeveloperTools?: boolean; + streamRailStart?: boolean; + streamRailEnd?: boolean; + isStream?: boolean; }; export const Event = as<'div', EventProps>( ( @@ -1203,6 +1265,9 @@ export const Event = as<'div', EventProps>( messageSpacing, hideReadReceipts, showDeveloperTools, + streamRailStart, + streamRailEnd, + isStream = false, children, ...props }, @@ -1241,7 +1306,7 @@ export const Event = as<'div', EventProps>( ( )} -
{children}
+ +
{children}
+
); } diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 0c055886..943c1b5b 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -31,12 +31,11 @@ import FocusTrap from 'focus-trap-react'; import { Page, PageContent, PageHeader } from '../../../components/page'; import { SequenceCard } from '../../../components/sequence-card'; import { useSetting } from '../../../state/hooks/settings'; -import { DateFormat, MessageLayout, MessageSpacing, settingsAtom } from '../../../state/settings'; +import { DateFormat, MessageSpacing, 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 { useMessageLayoutItems } from '../../../hooks/useMessageLayout'; import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing'; import { useDateFormatItems } from '../../../hooks/useDateFormat'; import { SequenceCardStyle } from '../styles.css'; @@ -534,75 +533,6 @@ function Editor() { ); } -function SelectMessageLayout() { - const [menuCords, setMenuCords] = useState(); - const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout'); - const messageLayoutItems = useMessageLayoutItems(); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(evt.currentTarget.getBoundingClientRect()); - }; - - const handleSelect = (layout: MessageLayout) => { - setMessageLayout(layout); - setMenuCords(undefined); - }; - - return ( - <> - - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - {messageLayoutItems.map((item) => ( - handleSelect(item.layout)} - > - {item.name} - - ))} - - - - } - /> - - ); -} - function SelectMessageSpacing() { const [menuCords, setMenuCords] = useState(); const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing'); @@ -695,10 +625,11 @@ function Messages() { {t('Settings.messages')} - } /> - - - } /> + } + /> (() => + enabled && !isOwn && !!eventId && !!myUserId ? room.hasUserReadEvent(myUserId, eventId) : false + ); + + useEffect(() => { + if (!enabled) return undefined; + if (isOwn || !eventId || !myUserId) return undefined; + setHaveSeen(room.hasUserReadEvent(myUserId, eventId)); + const handler: RoomEventHandlerMap[RoomEvent.Receipt] = (_event, r) => { + if (r.roomId !== room.roomId) return; + setHaveSeen(room.hasUserReadEvent(myUserId, eventId)); + }; + room.on(RoomEvent.Receipt, handler); + return () => { + room.removeListener(RoomEvent.Receipt, handler); + }; + }, [room, myUserId, eventId, isOwn, enabled]); + + if (!enabled) { + return { color: color.Primary.Main, opacity: 1.0 }; + } + + const pushActions = mx.getPushActionsForEvent(mEvent.replacingEvent() ?? mEvent); + if (pushActions?.tweaks?.highlight) { + return { color: color.Warning.Main, opacity: 1.0 }; + } + + if (isOwn) { + if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) { + return { color: color.Critical.Main, opacity: 1.0 }; + } + // Sending state always dims, regardless of hideReadReceipts — the in-flight + // signal is local to the sender and reveals nothing about the peer. + if (status === MessageDeliveryStatus.Sending) { + return { color: color.Primary.Main, opacity: 0.3 }; + } + if (hideReadReceipts) { + // Privacy mode: don't differentiate Sent vs Read — both render full, + // matching the single-checkmark behaviour of MessageStatus.tsx when + // the same flag is on. + return { color: color.Primary.Main, opacity: 1.0 }; + } + return { + color: color.Primary.Main, + // Stronger opacity contrast than the original 0.4: at 0.3 the dot still + // reads as Fleet-violet (own author colour) but is clearly less alive + // than the 1.0 read state. Author colour is preserved on purpose — the + // user explicitly asked NOT to grey-out / desaturate read dots, only to + // dim them through opacity. + opacity: status === MessageDeliveryStatus.Read ? 1.0 : 0.3, + }; + } + + return { + color: `var(${cssColorMXID(senderId)})`, + // Same logic for incoming dots: keep the author hash colour visible at + // all times so group-DM authorship stays scannable; only modulate the + // dot's brightness via opacity to mark «I've seen it» (0.3) vs fresh (1.0). + opacity: haveSeen ? 0.3 : 1.0, + }; +} diff --git a/src/app/hooks/useIsDirectStream.ts b/src/app/hooks/useIsDirectStream.ts new file mode 100644 index 00000000..8bb4c73b --- /dev/null +++ b/src/app/hooks/useIsDirectStream.ts @@ -0,0 +1,21 @@ +import { Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; +import { mDirectAtom } from '../state/mDirectList'; +import { isDirectStreamRoom } from '../utils/room'; +import { useMatrixClient } from './useMatrixClient'; + +// Render-side DM gate. The actual four-source decision tree lives in +// `isDirectStreamRoom` (utils/room.ts) so HomeRouteRoomProvider (cold-start +// push redirect), DirectRouteRoomProvider (destination validation), and +// useRoomNavigate.navigateRoom (imperative routing) can reuse the same +// logic without re-implementing it. All four call sites must stay in sync — +// any divergence reintroduces the cold-start race that flashes +// JoinBeforeNavigate before the room renders. +// Don't replace useIsDirectRoom() with this hook globally — that one is the +// route-level context value driven by DirectRouteRoomProvider; this one is the +// presentation-level gate for picking the Stream visual. +export const useIsDirectStream = (room: Room): boolean => { + const mx = useMatrixClient(); + const mDirects = useAtomValue(mDirectAtom); + return isDirectStreamRoom(mx, room, mDirects); +}; diff --git a/src/app/hooks/useMessageLayout.ts b/src/app/hooks/useMessageLayout.ts deleted file mode 100644 index 06fb9a5d..00000000 --- a/src/app/hooks/useMessageLayout.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useMemo } from 'react'; -import { MessageLayout } from '../state/settings'; - -export type MessageLayoutItem = { - name: string; - layout: MessageLayout; -}; - -export const useMessageLayoutItems = (): MessageLayoutItem[] => - useMemo( - () => [ - { - layout: MessageLayout.Modern, - name: 'Modern', - }, - { - layout: MessageLayout.Compact, - name: 'Compact', - }, - { - layout: MessageLayout.Bubble, - name: 'Bubble', - }, - ], - [] - ); diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 408d3df6..95579784 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -9,7 +9,7 @@ import { getSpaceRoomPath, } from '../pages/pathUtils'; import { useMatrixClient } from './useMatrixClient'; -import { getOrphanParents, guessPerfectParent } from '../utils/room'; +import { getOrphanParents, guessPerfectParent, isDirectStreamRoom } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { mDirectAtom } from '../state/mDirectList'; import { useSelectedSpace } from './router/useSelectedSpace'; @@ -88,7 +88,16 @@ export const useRoomNavigate = () => { return; } - if (mDirects.has(roomId)) { + // 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). + const targetRoom = mx.getRoom(roomId); + const isDirect = targetRoom + ? isDirectStreamRoom(mx, targetRoom, mDirects) + : mDirects.has(roomId); + if (isDirect) { safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); return; } diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 7c26ec54..c3c81bb4 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -1,20 +1,29 @@ import React, { ReactNode } from 'react'; import { useParams } from 'react-router-dom'; +import { useAtomValue } from 'jotai'; 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 { useDirectRooms } from './useDirectRooms'; +import { mDirectAtom } from '../../../state/mDirectList'; +import { isDirectStreamRoom } from '../../../utils/room'; export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); - const rooms = useDirectRooms(); + const mDirects = useAtomValue(mDirectAtom); const { roomIdOrAlias, eventId } = useParams(); const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); - if (!room || !rooms.includes(room.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)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 0e86c93f..5287f654 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -10,7 +10,7 @@ import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParams import { mDirectAtom } from '../../../state/mDirectList'; import { getDirectRoomPath } from '../../pathUtils'; import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { isDirectInvite } from '../../../utils/room'; +import { isDirectStreamRoom } from '../../../utils/room'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const mx = useMatrixClient(); @@ -23,15 +23,11 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { 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/. mDirectAtom is hydrated - // from useEffect so it can still be empty on the first frame after a fresh - // invite — fall back to the synchronous SDK signals (invite-state DMInviter - // or m.room.member content with is_direct: true) so the redirect lands on - // the first paint instead of one frame later. See plan §6.5 / §6.7. - if ( - room && - (mDirects.has(room.roomId) || !!room.getDMInviter() || isDirectInvite(room, mx.getUserId())) - ) { + // 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)) { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); return ; } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 05dfeb29..2c0d0cc1 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,6 +72,29 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; +// Synchronous «is this a DM that should render in the Stream layout?» predicate. +// Combines four first-frame-safe sources so every DM gate in the app — render +// (`useIsDirectStream`), cold-start push redirect (HomeRouteRoomProvider), +// imperative navigation (useRoomNavigate) — agrees on the answer: +// 1. Live `mDirectAtom` Set (caller passes it in to keep this a pure helper). +// 2. `room.getDMInviter()` for invite-state members with is_direct: true. +// 3. `isDirectInvite(room, myUserId)` for current member content is_direct. +// 4. `mx.getAccountData('m.direct')` SDK fallback for the first frame after +// cold-start when the atom is still being hydrated by useBindMDirectAtom. +// See docs/plans/dm_1x1_redesign.md §6.5 / §6.7. +export const isDirectStreamRoom = ( + mx: MatrixClient, + room: Room, + mDirects: Set +): boolean => { + if (mDirects.has(room.roomId)) return true; + if (room.getDMInviter()) return true; + if (isDirectInvite(room, mx.getUserId())) return true; + const event = getAccountData(mx, AccountDataEvent.Direct); + if (event && getMDirects(event).has(room.roomId)) return true; + return false; +}; + export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate);