redesign(p3a): land Stream message layout for DMs with rail, author dots, asymmetric bubbles, Stream day-divider, and sysline state events

This commit is contained in:
v.lagerev 2026-04-28 00:54:53 +03:00
parent 8943824b9e
commit 1c5ecb5309
22 changed files with 1584 additions and 207 deletions

View file

@ -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 `<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.
## 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 → `<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-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`

View file

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

View file

@ -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": "Сервер",

View file

@ -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&': {

View file

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

View file

@ -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) => (
<Box
className={classNames(css.Reply, className)}
alignItems="Center"
gap="100"
// Tint the quote-bar with the replied-to user's hash colour. The CSS
// variable is consumed by Reply.css.ts::Reply borderLeft. Falls back
// to a neutral SurfaceVariant.ContainerLine when userColor is undefined.
style={
userColor
? ({ ...(style ?? {}), [css.ReplyBarColor]: userColor } as React.CSSProperties)
: style
}
{...props}
ref={ref}
>

View file

@ -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<StreamContext>({ 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<HTMLDivElement>(null);
const timeRef = useRef<HTMLDivElement>(null);
const railRef = useRef<HTMLSpanElement>(null);
const dotRef = useRef<HTMLSpanElement>(null);
const bodyRef = useRef<HTMLDivElement>(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 (
<div
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
ref={rootRef}
>
<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. */}
</div>
);
}
const beforeJSX = (
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
{messageLayout === MessageLayout.Compact && time}

View file

@ -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 `<MessageStatus>` 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 (
<div
className={classNames(css.StreamDayRoot({ compact }), className)}
{...props}
ref={ref}
>
{/* Rail segment under the row same recipe as message rows so the
rail flows continuously through the day boundary. */}
<span className={css.StreamRail} aria-hidden />
<div className={css.StreamDayLabel}>{label}</div>
<span className={css.StreamDayDot} aria-hidden />
<div className={css.StreamDayLine} aria-hidden />
</div>
);
}
);
export const StreamLayout = as<'div', StreamLayoutProps>(
(
{
className,
time,
dotColor,
dotOpacity,
isOwn,
compact,
header,
railStart,
railEnd,
children,
...props
},
ref
) => {
const rootRef = useRef<HTMLDivElement>(null);
const timeRef = useRef<HTMLSpanElement>(null);
const railRef = useRef<HTMLSpanElement>(null);
const dotRef = useRef<HTMLSpanElement>(null);
const bubbleRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(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 (
<div
className={classNames(css.StreamRoot({ compact: !!compact }), className)}
{...props}
ref={rootRef}
>
<span
className={classNames(
css.StreamRail,
railStart && railEnd && css.StreamRailSingle,
railStart && !railEnd && css.StreamRailStart,
railEnd && !railStart && css.StreamRailEnd
)}
aria-hidden
ref={railRef}
/>
<div className={css.StreamBubble({ own: !!isOwn, compact: !!compact })} ref={bubbleRef}>
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
<span
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
aria-hidden
ref={dotRef}
>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
{header && (
<div className={css.StreamBubbleHeader} ref={headerRef}>
{header}
</div>
)}
{children}
</div>
</div>
);
}
);

View file

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

View file

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

View file

@ -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<HTMLElement>;
timeColumn: RefObject<HTMLElement>;
rail: RefObject<HTMLElement>;
dot: RefObject<HTMLElement>;
content?: RefObject<HTMLElement>;
header?: RefObject<HTMLElement>;
};
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<string, string> = {};
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<typeof inspectElement>;
timeColumn?: ReturnType<typeof inspectElement>;
time?: ReturnType<typeof inspectElement>;
dot?: ReturnType<typeof inspectElement>;
rail?: ReturnType<typeof inspectElement>;
content?: ReturnType<typeof inspectElement>;
header?: ReturnType<typeof inspectElement>;
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;

View file

@ -20,7 +20,7 @@ export function UnreadBadgeCenter({ children }: { children: ReactNode }) {
export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
return (
<Badge
variant={highlight ? 'Success' : 'Secondary'}
variant={highlight ? 'Critical' : 'Primary'}
size={count > 0 ? '400' : '200'}
fill="Solid"
radii="Pill"

View file

@ -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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1139,7 +1184,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Message>
);
},
[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 <RedactedContent />;
@ -1274,7 +1330,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</EncryptedContent>
);
},
[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() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1338,18 +1405,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Message>
);
},
[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 = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1367,11 +1444,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
time={timeJSX}
iconSrc={parsed.icon}
iconSrc={iconSrc}
content={
<Box grow="Yes" direction="Column">
<Text size="T300" priority="300">
@ -1383,7 +1463,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
[StateEvent.RoomName]: (mEventId, mEvent, item) => {
[StateEvent.RoomName]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
@ -1391,7 +1479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1409,6 +1497,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1426,7 +1517,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
[StateEvent.RoomTopic]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
@ -1434,7 +1533,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1452,6 +1551,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1469,7 +1571,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
[StateEvent.RoomAvatar]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
@ -1477,7 +1587,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1495,6 +1605,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1512,7 +1625,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
[StateEvent.GroupCallMemberPrefix]: (
mEventId,
mEvent,
item,
_timelineSet,
_collapse,
streamRailStart,
streamRailEnd
) => {
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
@ -1528,7 +1649,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1546,6 +1667,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1567,7 +1691,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
);
},
},
(mEventId, mEvent, item) => {
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
if (!showHiddenEvents) return null;
const highlighted = focusItem?.index === item && focusItem.highlight;
const senderId = mEvent.getSender() ?? '';
@ -1576,7 +1700,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1594,6 +1718,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1613,7 +1740,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</Event>
);
},
(mEventId, mEvent, item) => {
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
if (!showHiddenEvents) return null;
if (Object.keys(mEvent.getContent()).length === 0) return null;
if (mEvent.getRelation()) return null;
@ -1626,7 +1753,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const timeJSX = (
<Time
ts={mEvent.getTs()}
compact={messageLayout === MessageLayout.Compact}
compact={isStream || messageLayout === MessageLayout.Compact}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
@ -1644,6 +1771,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
hideReadReceipts={hideActivity}
showDeveloperTools={showDeveloperTools}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
isStream={isStream}
>
<EventContent
messageLayout={messageLayout}
@ -1669,6 +1799,109 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
let isPrevRendered = false;
let newDivider = false;
let dayDivider = false;
// Keep this in sync with renderMatrixEvent early `return null` branches above:
// Stream rail endpoints (start/end) use this predicate to skip hidden
// service / reaction / edit events when deciding whether a visible row is
// the first or last timeline dot.
const isRenderableTimelineEvent = (event: MatrixEvent): boolean => {
const eventIdForRender = event.getId();
if (!eventIdForRender) return false;
const senderId = event.getSender();
if (senderId && ignoredUsersSet.has(senderId)) return false;
if (event.isRedacted() && !showHiddenEvents) return false;
if (reactionOrEditEvent(event)) return false;
// RTC service events (notifications / declines) — once decrypted, the
// SDK's getType() returns the inner type, so this filter catches them.
// While still encrypted+pending-decryption, the event passes the
// RoomMessageEncrypted branch below as «renderable»; once decryption
// completes, the timeline emits a fresh render that re-evaluates this
// predicate with the inner type and falls through to false. The
// transient one-frame mismatch on rail endpoint computation self-heals
// on the very next pass — no code path needed.
const eventType = event.getType();
if (eventType === 'org.matrix.msc4075.rtc.notification') return false;
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
if (eventType === StateEvent.RoomMember) {
const membershipChanged = isMembershipChanged(event);
if (membershipChanged && hideMembershipEvents) return false;
if (!membershipChanged && hideNickAvatarEvents) return false;
return true;
}
if (eventType === StateEvent.GroupCallMemberPrefix) {
const content = event.getContent<SessionMembershipData>();
const prevContent = event.getPrevContent();
const callJoined = content.application;
if (callJoined && 'application' in prevContent) return false;
return true;
}
if (
eventType === MessageEvent.RoomMessage ||
eventType === MessageEvent.RoomMessageEncrypted ||
eventType === MessageEvent.Sticker ||
eventType === StateEvent.RoomName ||
eventType === StateEvent.RoomTopic ||
eventType === StateEvent.RoomAvatar
) {
return true;
}
if (typeof event.getStateKey() === 'string') return showHiddenEvents;
if (!showHiddenEvents) return false;
if (Object.keys(event.getContent()).length === 0) return false;
if (event.getRelation()) return false;
// Note: redactions are intentionally NOT filtered here. The catch-all
// renderer still renders them when showHiddenEvents=true (dev tools),
// so the predicate must agree — otherwise rail endpoints would treat a
// visible redaction event as invisible and miscount «is there a renderable
// event before/after this row» on the dev-tools path.
return true;
};
const getTimelineItemEvent = (item: number): MatrixEvent | undefined => {
const [itemTimeline, itemBaseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!itemTimeline) return undefined;
return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex));
};
// Single forward + reverse pass that records, for each visible item, whether
// there is any RENDERABLE event before / after it. Used to compute Stream
// rail-start (no renderable before) and rail-end (no renderable after).
// Crucially this looks at renderability, not at `isPrevRendered` — the
// latter is mutated by reaction / edit / hidden service events and would
// otherwise reset rail-start in the middle of a continuous DM thread.
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) => {
const ev = getTimelineItemEvent(item);
return !!ev && isRenderableTimelineEvent(ev);
});
let seenBefore = false;
for (let index = 0; index < items.length; index += 1) {
before.set(items[index], seenBefore);
if (renderableFlags[index]) seenBefore = true;
}
let seenAfter = false;
for (let index = items.length - 1; index >= 0; index -= 1) {
after.set(items[index], seenAfter);
if (renderableFlags[index]) seenAfter = true;
}
return { before, after };
})();
const eventRenderer = (item: number) => {
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
if (!eventTimeline) return null;
@ -1702,6 +1935,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
// streamRailStart looks at the precomputed «is there a renderable event
// before me in the visible window» — not at `isPrevRendered`, which is
// false for the row right after a reaction / edit / hidden service event
// and would otherwise restart the rail mid-conversation. Symmetric with
// streamRailEnd: only declare a row to be the rail's first dot when the
// visible window is sitting at the genuine timeline start AND no further
// 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;
const streamRailEnd =
isStream &&
liveTimelineLinked &&
rangeAtEnd &&
streamRenderableItemHasAfter.get(item) !== true;
const eventJSX = reactionOrEditEvent(mEvent)
? null
: renderMatrixEvent(
@ -1711,13 +1963,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent,
item,
timelineSet,
collapsed
collapsed,
streamRailStart,
streamRailEnd
);
prevEvent = mEvent;
isPrevRendered = !!eventJSX;
const newDividerJSX =
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
!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">
@ -1726,23 +1980,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
</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.
newDivider = false;
}
const dayDividerJSX =
dayDivider && eventJSX ? (
const dayLabel = (() => {
if (today(mEvent.getTs())) return t('Room.today');
if (yesterday(mEvent.getTs())) return t('Room.yesterday');
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">
{(() => {
if (today(mEvent.getTs())) return t('Room.today');
if (yesterday(mEvent.getTs())) return t('Room.yesterday');
return timeDayMonthYear(mEvent.getTs());
})()}
</Text>
<Text size="L400">{dayLabel}</Text>
</Badge>
</TimelineDivider>
</MessageBase>
) : null;
);
};
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
if (newDividerJSX) newDivider = false;

View file

@ -41,9 +41,12 @@ import {
AvatarBase,
BubbleLayout,
CompactLayout,
IsStreamProvider,
MessageBase,
MessageStatus,
ModernLayout,
STREAM_MESSAGE_SPACING,
StreamLayout,
Time,
Username,
UsernameBold,
@ -58,6 +61,8 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import * as css from './styles.css';
import { EventReaders } from '../../../components/event-readers';
import { TextViewer } from '../../../components/text-viewer';
@ -684,6 +689,9 @@ export type MessageProps = {
legacyUsernameColor?: boolean;
hour24Clock: boolean;
dateFormatString: string;
streamRailStart?: boolean;
streamRailEnd?: boolean;
isStream?: boolean;
};
export const Message = as<'div', MessageProps>(
(
@ -715,6 +723,9 @@ export const Message = as<'div', MessageProps>(
legacyUsernameColor,
hour24Clock,
dateFormatString,
streamRailStart,
streamRailEnd,
isStream = false,
children,
...props
},
@ -746,9 +757,16 @@ export const Message = as<'div', MessageProps>(
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const isBubble = messageLayout === MessageLayout.Bubble;
const dot = useDotColor(room, mEvent, isStream, hideReadReceipts);
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const showTimeInHeader = !isBubble || !isOwnMessage;
const headerJSX = !collapse && (
// 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'}
@ -796,7 +814,7 @@ export const Message = as<'div', MessageProps>(
</Box>
);
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
const avatarJSX = !isStream && !collapse && messageLayout !== MessageLayout.Compact && (
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
@ -821,7 +839,7 @@ export const Message = as<'div', MessageProps>(
</AvatarBase>
);
const bubbleMetaJSX = isBubble && isOwnMessage && (
const bubbleMetaJSX = !isStream && isBubble && isOwnMessage && (
<span className={css.BubbleMeta}>
<Time
ts={mEvent.getTs()}
@ -900,7 +918,7 @@ export const Message = as<'div', MessageProps>(
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
})}
tabIndex={0}
space={messageSpacing}
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
collapse={collapse}
highlight={highlight}
selected={!!menuAnchor || !!emojiBoardAnchor}
@ -1149,34 +1167,75 @@ export const Message = as<'div', MessageProps>(
</Menu>
</div>
)}
{messageLayout === MessageLayout.Compact && (
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
{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}
</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>
) : (
<>
{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>
)}
</>
)}
</MessageBase>
);
@ -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>(
<MessageBase
className={classNames(css.MessageBase, className)}
tabIndex={0}
space={messageSpacing}
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
autoCollapse
highlight={highlight}
selected={!!menuAnchor}
@ -1328,7 +1393,11 @@ export const Event = as<'div', EventProps>(
</Menu>
</div>
)}
<div onContextMenu={handleContextMenu}>{children}</div>
<IsStreamProvider
value={{ enabled: isStream, railStart: streamRailStart, railEnd: streamRailEnd }}
>
<div onContextMenu={handleContextMenu}>{children}</div>
</IsStreamProvider>
</MessageBase>
);
}

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, 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<RectCords>();
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
const messageLayoutItems = useMessageLayoutItems();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (layout: MessageLayout) => {
setMessageLayout(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">
{messageLayoutItems.find((i) => i.layout === messageLayout)?.name ?? messageLayout}
</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 }}>
{messageLayoutItems.map((item) => (
<MenuItem
key={item.layout}
size="300"
variant={messageLayout === item.layout ? 'Primary' : 'Surface'}
radii="300"
onClick={() => handleSelect(item.layout)}
>
<Text size="T300">{item.name}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SelectMessageSpacing() {
const [menuCords, setMenuCords] = useState<RectCords>();
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
@ -695,10 +625,11 @@ function Messages() {
<Box direction="Column" gap="100">
<Text size="L400">{t('Settings.messages')}</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title={t('Settings.message_layout')} after={<SelectMessageLayout />} />
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title={t('Settings.message_spacing')} after={<SelectMessageSpacing />} />
<SettingTile
title={t('Settings.message_spacing')}
description={t('Settings.message_spacing_dm_note')}
after={<SelectMessageSpacing />}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile

View file

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react';
import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { color } from 'folds';
import { cssColorMXID } from '../../util/colorMXID';
import { useMatrixClient } from './useMatrixClient';
import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus';
export type DotColor = { color: string; opacity: number };
// Variant A + C decision tree (docs/plans/dm_1x1_redesign.md §6.5b):
// * mention-highlight (push-actions tweaks.highlight) → Warning gold, override
// * own NOT_SENT / CANCELLED → Critical, full
// * own otherwise → Primary, dim → full when peer reads
// * incoming → cssColorMXID hash, full → dim once we read
//
// Mention detection goes through the canonical `mx.getPushActionsForEvent`
// path (m.mentions, display-name match, configured keywords, room-mentions via
// power-level) — same as element-web. Don't roll our own mentionsMe() helper.
//
// `replacingEvent()` is consulted so a non-mention edit of a mention drops the
// gold dot and a mention added via edit lights one up.
//
// Read-state for incoming messages is reactive: we subscribe to RoomEvent.Receipt
// so the opacity flips when our own read receipt lands (e.g. another tab marks
// the room read). For own messages, useMessageStatus already wires this up via
// its internal effect.
//
// `hideReadReceipts` mirrors the legacy MessageStatus checkmark suppression:
// when the user opted into «Hide Typing & Read Receipts» we MUST NOT reveal
// peer-read state via the dot either, otherwise the privacy guarantee is
// asymmetric. With the flag on, own messages stop dimming once they leave the
// «sending» state — Sent and Read both render at full opacity.
export function useDotColor(
room: Room,
mEvent: MatrixEvent,
enabled = true,
hideReadReceipts = false
): DotColor {
const mx = useMatrixClient();
const myUserId = mx.getUserId() ?? '';
const senderId = mEvent.getSender() ?? '';
const isOwn = !!myUserId && senderId === myUserId;
const eventId = mEvent.getId();
const status = useMessageStatus(room, mEvent);
const [haveSeen, setHaveSeen] = useState<boolean>(() =>
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,
};
}

View file

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

View file

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

View file

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

View file

@ -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 <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
}

View file

@ -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 <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
}

View file

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