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:
parent
e230e688de
commit
5bf0aeb00b
22 changed files with 1584 additions and 207 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Сервер",
|
||||
|
|
|
|||
|
|
@ -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&': {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
168
src/app/components/message/layout/Stream.tsx
Normal file
168
src/app/components/message/layout/Stream.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
export * from './Modern';
|
||||
export * from './Compact';
|
||||
export * from './Bubble';
|
||||
export * from './Stream';
|
||||
export * from './Base';
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
221
src/app/components/message/layout/streamDebug.ts
Normal file
221
src/app/components/message/layout/streamDebug.ts
Normal 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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
107
src/app/hooks/useDotColor.ts
Normal file
107
src/app/hooks/useDotColor.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/app/hooks/useIsDirectStream.ts
Normal file
21
src/app/hooks/useIsDirectStream.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue