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
|
→ 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/`)
|
## Features (`src/app/features/`)
|
||||||
|
|
||||||
| Dir | Purpose |
|
| 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/` | 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-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 |
|
| `room-settings/` | Room-specific settings page |
|
||||||
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
||||||
| `space-settings/` | Space-specific settings |
|
| `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 |
|
| `lobby/` | Space/room lobby view |
|
||||||
| `search/` | Global search |
|
| `search/` | Global search |
|
||||||
| `message-search/` | In-room message 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/`)
|
## 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).
|
- `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.
|
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
|
||||||
- `emoji-board/` — Emoji picker
|
- `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/`:
|
**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
|
- `sessions.ts` — Active session
|
||||||
- `upload.ts` — Upload progress (in-memory)
|
- `upload.ts` — Upload progress (in-memory)
|
||||||
- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
|
- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,8 @@
|
||||||
"hide_activity": "Hide Typing & Read Receipts",
|
"hide_activity": "Hide Typing & Read Receipts",
|
||||||
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
|
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
|
||||||
"messages": "Messages",
|
"messages": "Messages",
|
||||||
"message_layout": "Message Layout",
|
|
||||||
"message_spacing": "Message Spacing",
|
"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",
|
"legacy_username_color": "Legacy Username Color",
|
||||||
"hide_membership": "Hide Membership Change",
|
"hide_membership": "Hide Membership Change",
|
||||||
"hide_profile": "Hide Profile Change",
|
"hide_profile": "Hide Profile Change",
|
||||||
|
|
@ -377,8 +377,8 @@
|
||||||
"segment_coming_soon": "Coming soon",
|
"segment_coming_soon": "Coming soon",
|
||||||
"self_row_label": "You",
|
"self_row_label": "You",
|
||||||
"self_row_preview": "Settings & profile",
|
"self_row_preview": "Settings & profile",
|
||||||
|
"message_me_label": "me",
|
||||||
"status_e2ee": "e2ee",
|
"status_e2ee": "e2ee",
|
||||||
"chats": "Chats",
|
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"username_placeholder": "username",
|
"username_placeholder": "username",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
|
|
|
||||||
|
|
@ -128,8 +128,8 @@
|
||||||
"hide_activity": "Скрыть набор текста и уведомления о прочтении",
|
"hide_activity": "Скрыть набор текста и уведомления о прочтении",
|
||||||
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
|
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
|
||||||
"messages": "Сообщения",
|
"messages": "Сообщения",
|
||||||
"message_layout": "Макет сообщений",
|
|
||||||
"message_spacing": "Интервал сообщений",
|
"message_spacing": "Интервал сообщений",
|
||||||
|
"message_spacing_dm_note": "В личных чатах используется фиксированный интервал, чтобы линия таймлайна оставалась непрерывной.",
|
||||||
"legacy_username_color": "Классический цвет имени",
|
"legacy_username_color": "Классический цвет имени",
|
||||||
"hide_membership": "Скрыть изменения участников",
|
"hide_membership": "Скрыть изменения участников",
|
||||||
"hide_profile": "Скрыть изменения профиля",
|
"hide_profile": "Скрыть изменения профиля",
|
||||||
|
|
@ -377,8 +377,8 @@
|
||||||
"segment_coming_soon": "Скоро",
|
"segment_coming_soon": "Скоро",
|
||||||
"self_row_label": "Я",
|
"self_row_label": "Я",
|
||||||
"self_row_preview": "Настройки и профиль",
|
"self_row_preview": "Настройки и профиль",
|
||||||
|
"message_me_label": "я",
|
||||||
"status_e2ee": "e2ee",
|
"status_e2ee": "e2ee",
|
||||||
"chats": "Чаты",
|
|
||||||
"username": "Имя пользователя",
|
"username": "Имя пользователя",
|
||||||
"username_placeholder": "username",
|
"username_placeholder": "username",
|
||||||
"server": "Сервер",
|
"server": "Сервер",
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ export const Reaction = style([
|
||||||
padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
|
padding: `${toRem(2)} ${config.space.S200} ${toRem(2)} ${config.space.S100}`,
|
||||||
backgroundColor: Container,
|
backgroundColor: Container,
|
||||||
border: `${config.borderWidth.B300} solid ${ContainerLine}`,
|
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: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const ReplyBend = style({
|
export const ReplyBend = style({
|
||||||
flexShrink: 0,
|
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({
|
export const Reply = style({
|
||||||
|
vars: {
|
||||||
|
[ReplyBarColor]: color.SurfaceVariant.ContainerLine,
|
||||||
|
},
|
||||||
marginBottom: toRem(1),
|
marginBottom: toRem(1),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
minHeight: config.lineHeight.T300,
|
minHeight: config.lineHeight.T300,
|
||||||
|
paddingLeft: toRem(8),
|
||||||
|
borderLeft: `${toRem(2)} solid ${ReplyBarColor}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
'button&': {
|
'button&': {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,19 @@ type ReplyLayoutProps = {
|
||||||
username?: ReactNode;
|
username?: ReactNode;
|
||||||
};
|
};
|
||||||
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
export const ReplyLayout = as<'div', ReplyLayoutProps>(
|
||||||
({ username, userColor, className, children, ...props }, ref) => (
|
({ username, userColor, className, style, children, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
className={classNames(css.Reply, className)}
|
className={classNames(css.Reply, className)}
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
gap="100"
|
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}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,96 @@
|
||||||
import { Box, Icon, IconSrc } from 'folds';
|
import { Box, Icon, IconSrc, color } from 'folds';
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode, createContext, useContext, useRef } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
||||||
import { MessageLayout } from '../../../state/settings';
|
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 = {
|
export type EventContentProps = {
|
||||||
messageLayout: number;
|
messageLayout: MessageLayout;
|
||||||
time: ReactNode;
|
time: ReactNode;
|
||||||
iconSrc: IconSrc;
|
iconSrc: IconSrc;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
};
|
};
|
||||||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
|
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 = (
|
const beforeJSX = (
|
||||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||||
{messageLayout === MessageLayout.Compact && time}
|
{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 './Modern';
|
||||||
export * from './Compact';
|
export * from './Compact';
|
||||||
export * from './Bubble';
|
export * from './Bubble';
|
||||||
|
export * from './Stream';
|
||||||
export * from './Base';
|
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 { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
|
@ -179,6 +179,476 @@ export const UsernameBold = style({
|
||||||
fontWeight: 550,
|
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({
|
export const MessageTextBody = recipe({
|
||||||
base: {
|
base: {
|
||||||
wordBreak: 'break-word',
|
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) {
|
export function UnreadBadge({ highlight, count }: UnreadBadgeProps) {
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant={highlight ? 'Success' : 'Secondary'}
|
variant={highlight ? 'Critical' : 'Primary'}
|
||||||
size={count > 0 ? '400' : '200'}
|
size={count > 0 ? '400' : '200'}
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,8 @@ import {
|
||||||
MSticker,
|
MSticker,
|
||||||
ImageContent,
|
ImageContent,
|
||||||
EventContent,
|
EventContent,
|
||||||
|
STREAM_MESSAGE_SPACING,
|
||||||
|
StreamDayDivider,
|
||||||
} from '../../components/message';
|
} from '../../components/message';
|
||||||
import {
|
import {
|
||||||
factoryRenderLinkifyWithMention,
|
factoryRenderLinkifyWithMention,
|
||||||
|
|
@ -119,6 +121,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { useIsDirectStream } from '../../hooks/useIsDirectStream';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
|
|
@ -439,6 +442,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||||
const direct = useIsDirectRoom();
|
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 [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
|
|
@ -608,11 +616,24 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room,
|
room,
|
||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(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
|
// if user is at bottom of timeline
|
||||||
// keep paginating timeline and conditionally mark as read
|
// keep paginating timeline and conditionally mark as read
|
||||||
// otherwise we update timeline without paginating
|
// otherwise we update timeline without paginating
|
||||||
// so timeline can be updated with evt like: edits, reactions etc
|
// 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())) {
|
if (document.hasFocus() && (!unreadInfo || mEvt.getSender() === mx.getUserId())) {
|
||||||
// Check if the document is in focus (user is actively viewing the app),
|
// 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.
|
// 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.count += 1;
|
||||||
scrollToBottomRef.current.smooth = mEvt.getSender() !== mx.getUserId();
|
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) => ({
|
setTimeline((ct) => {
|
||||||
...ct,
|
if (!atLiveEndRef.current && isOwnLiveStreamMessage) {
|
||||||
range: {
|
return getInitialTimeline(room);
|
||||||
start: ct.range.start + 1,
|
}
|
||||||
end: ct.range.end + 1,
|
return {
|
||||||
},
|
...ct,
|
||||||
}));
|
range: {
|
||||||
|
start: ct.range.start + 1,
|
||||||
|
end: ct.range.end + 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeline((ct) => ({ ...ct }));
|
setTimeline((ct) => ({ ...ct }));
|
||||||
|
|
@ -641,7 +675,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const renderMatrixEvent = useMatrixEventRenderer<
|
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
|
// 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.
|
// when MSC4075/MSC4310 stabilize.
|
||||||
'org.matrix.msc4075.rtc.notification': () => null,
|
'org.matrix.msc4075.rtc.notification': () => null,
|
||||||
'org.matrix.msc4310.rtc.decline': () => 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 reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactions && reactions.length > 0;
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
|
|
@ -1119,6 +1161,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1139,7 +1184,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[MessageEvent.RoomMessageEncrypted]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
[MessageEvent.RoomMessageEncrypted]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
timelineSet,
|
||||||
|
collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactions && reactions.length > 0;
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
|
|
@ -1213,6 +1266,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||||
|
|
@ -1274,7 +1330,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</EncryptedContent>
|
</EncryptedContent>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[MessageEvent.Sticker]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
[MessageEvent.Sticker]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
timelineSet,
|
||||||
|
collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactions && reactions.length > 0;
|
const hasReactions = reactions && reactions.length > 0;
|
||||||
|
|
@ -1319,6 +1383,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={legacyUsernameColor || direct}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1338,18 +1405,28 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Message>
|
</Message>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[StateEvent.RoomMember]: (mEventId, mEvent, item) => {
|
[StateEvent.RoomMember]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
_timelineSet,
|
||||||
|
_collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const membershipChanged = isMembershipChanged(mEvent);
|
const membershipChanged = isMembershipChanged(mEvent);
|
||||||
if (membershipChanged && hideMembershipEvents) return null;
|
if (membershipChanged && hideMembershipEvents) return null;
|
||||||
if (!membershipChanged && hideNickAvatarEvents) return null;
|
if (!membershipChanged && hideNickAvatarEvents) return null;
|
||||||
|
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const parsed = parseMemberEvent(mEvent);
|
const parsed = parseMemberEvent(mEvent);
|
||||||
|
const iconSrc =
|
||||||
|
isStream && parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1367,11 +1444,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
iconSrc={parsed.icon}
|
iconSrc={iconSrc}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Text size="T300" priority="300">
|
<Text size="T300" priority="300">
|
||||||
|
|
@ -1383,7 +1463,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[StateEvent.RoomName]: (mEventId, mEvent, item) => {
|
[StateEvent.RoomName]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
_timelineSet,
|
||||||
|
_collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
@ -1391,7 +1479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1409,6 +1497,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1426,7 +1517,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[StateEvent.RoomTopic]: (mEventId, mEvent, item) => {
|
[StateEvent.RoomTopic]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
_timelineSet,
|
||||||
|
_collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
@ -1434,7 +1533,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1452,6 +1551,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1469,7 +1571,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[StateEvent.RoomAvatar]: (mEventId, mEvent, item) => {
|
[StateEvent.RoomAvatar]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
_timelineSet,
|
||||||
|
_collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
@ -1477,7 +1587,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1495,6 +1605,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1512,7 +1625,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[StateEvent.GroupCallMemberPrefix]: (mEventId, mEvent, item) => {
|
[StateEvent.GroupCallMemberPrefix]: (
|
||||||
|
mEventId,
|
||||||
|
mEvent,
|
||||||
|
item,
|
||||||
|
_timelineSet,
|
||||||
|
_collapse,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
|
) => {
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId);
|
||||||
|
|
@ -1528,7 +1649,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1546,6 +1667,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
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;
|
if (!showHiddenEvents) return null;
|
||||||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
|
@ -1576,7 +1700,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1594,6 +1718,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1613,7 +1740,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</Event>
|
</Event>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
(mEventId, mEvent, item) => {
|
(mEventId, mEvent, item, _timelineSet, _collapse, streamRailStart, streamRailEnd) => {
|
||||||
if (!showHiddenEvents) return null;
|
if (!showHiddenEvents) return null;
|
||||||
if (Object.keys(mEvent.getContent()).length === 0) return null;
|
if (Object.keys(mEvent.getContent()).length === 0) return null;
|
||||||
if (mEvent.getRelation()) return null;
|
if (mEvent.getRelation()) return null;
|
||||||
|
|
@ -1626,7 +1753,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={messageLayout === MessageLayout.Compact}
|
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1644,6 +1771,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
|
streamRailStart={streamRailStart}
|
||||||
|
streamRailEnd={streamRailEnd}
|
||||||
|
isStream={isStream}
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
|
|
@ -1669,6 +1799,109 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
let isPrevRendered = false;
|
let isPrevRendered = false;
|
||||||
let newDivider = false;
|
let newDivider = false;
|
||||||
let dayDivider = 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 eventRenderer = (item: number) => {
|
||||||
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
const [eventTimeline, baseIndex] = getTimelineAndBaseIndex(timeline.linkedTimelines, item);
|
||||||
if (!eventTimeline) return null;
|
if (!eventTimeline) return null;
|
||||||
|
|
@ -1702,6 +1935,25 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
prevEvent.getType() === mEvent.getType() &&
|
prevEvent.getType() === mEvent.getType() &&
|
||||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
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)
|
const eventJSX = reactionOrEditEvent(mEvent)
|
||||||
? null
|
? null
|
||||||
: renderMatrixEvent(
|
: renderMatrixEvent(
|
||||||
|
|
@ -1711,13 +1963,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
mEvent,
|
mEvent,
|
||||||
item,
|
item,
|
||||||
timelineSet,
|
timelineSet,
|
||||||
collapsed
|
collapsed,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd
|
||||||
);
|
);
|
||||||
prevEvent = mEvent;
|
prevEvent = mEvent;
|
||||||
isPrevRendered = !!eventJSX;
|
isPrevRendered = !!eventJSX;
|
||||||
|
|
||||||
const newDividerJSX =
|
const newDividerJSX =
|
||||||
newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
!isStream && newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
||||||
<MessageBase space={messageSpacing}>
|
<MessageBase space={messageSpacing}>
|
||||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
||||||
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
||||||
|
|
@ -1726,23 +1980,38 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
</TimelineDivider>
|
</TimelineDivider>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
) : null;
|
) : 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 =
|
const dayLabel = (() => {
|
||||||
dayDivider && eventJSX ? (
|
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}>
|
<MessageBase space={messageSpacing}>
|
||||||
<TimelineDivider variant="Surface">
|
<TimelineDivider variant="Surface">
|
||||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||||
<Text size="L400">
|
<Text size="L400">{dayLabel}</Text>
|
||||||
{(() => {
|
|
||||||
if (today(mEvent.getTs())) return t('Room.today');
|
|
||||||
if (yesterday(mEvent.getTs())) return t('Room.yesterday');
|
|
||||||
return timeDayMonthYear(mEvent.getTs());
|
|
||||||
})()}
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</TimelineDivider>
|
</TimelineDivider>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
) : null;
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
|
||||||
|
|
||||||
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
||||||
if (newDividerJSX) newDivider = false;
|
if (newDividerJSX) newDivider = false;
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,12 @@ import {
|
||||||
AvatarBase,
|
AvatarBase,
|
||||||
BubbleLayout,
|
BubbleLayout,
|
||||||
CompactLayout,
|
CompactLayout,
|
||||||
|
IsStreamProvider,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
MessageStatus,
|
MessageStatus,
|
||||||
ModernLayout,
|
ModernLayout,
|
||||||
|
STREAM_MESSAGE_SPACING,
|
||||||
|
StreamLayout,
|
||||||
Time,
|
Time,
|
||||||
Username,
|
Username,
|
||||||
UsernameBold,
|
UsernameBold,
|
||||||
|
|
@ -58,6 +61,8 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
|
import { useDotColor } from '../../../hooks/useDotColor';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { EventReaders } from '../../../components/event-readers';
|
import { EventReaders } from '../../../components/event-readers';
|
||||||
import { TextViewer } from '../../../components/text-viewer';
|
import { TextViewer } from '../../../components/text-viewer';
|
||||||
|
|
@ -684,6 +689,9 @@ export type MessageProps = {
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
hour24Clock: boolean;
|
hour24Clock: boolean;
|
||||||
dateFormatString: string;
|
dateFormatString: string;
|
||||||
|
streamRailStart?: boolean;
|
||||||
|
streamRailEnd?: boolean;
|
||||||
|
isStream?: boolean;
|
||||||
};
|
};
|
||||||
export const Message = as<'div', MessageProps>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -715,6 +723,9 @@ export const Message = as<'div', MessageProps>(
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
hour24Clock,
|
hour24Clock,
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd,
|
||||||
|
isStream = false,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -746,9 +757,16 @@ export const Message = as<'div', MessageProps>(
|
||||||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||||
|
|
||||||
const isBubble = messageLayout === MessageLayout.Bubble;
|
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 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
|
<Box
|
||||||
gap="300"
|
gap="300"
|
||||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||||
|
|
@ -796,7 +814,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const avatarJSX = !collapse && messageLayout !== MessageLayout.Compact && (
|
const avatarJSX = !isStream && !collapse && messageLayout !== MessageLayout.Compact && (
|
||||||
<AvatarBase
|
<AvatarBase
|
||||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||||
>
|
>
|
||||||
|
|
@ -821,7 +839,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
const bubbleMetaJSX = isBubble && isOwnMessage && (
|
const bubbleMetaJSX = !isStream && isBubble && isOwnMessage && (
|
||||||
<span className={css.BubbleMeta}>
|
<span className={css.BubbleMeta}>
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
|
|
@ -900,7 +918,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
||||||
})}
|
})}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={messageSpacing}
|
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||||
|
|
@ -1149,34 +1167,75 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messageLayout === MessageLayout.Compact && (
|
{isStream ? (
|
||||||
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
<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}
|
{msgContentJSX}
|
||||||
</CompactLayout>
|
</StreamLayout>
|
||||||
)}
|
) : (
|
||||||
{isBubble && isOwnMessage && (
|
<>
|
||||||
<Box gap="200" alignItems="End">
|
{messageLayout === MessageLayout.Compact && (
|
||||||
<BubbleLayout
|
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
||||||
style={{ flexGrow: 1, minWidth: 0 }}
|
{msgContentJSX}
|
||||||
before={avatarJSX}
|
</CompactLayout>
|
||||||
header={headerJSX}
|
)}
|
||||||
onContextMenu={handleContextMenu}
|
{isBubble && isOwnMessage && (
|
||||||
>
|
<Box gap="200" alignItems="End">
|
||||||
{msgContentJSX}
|
<BubbleLayout
|
||||||
</BubbleLayout>
|
style={{ flexGrow: 1, minWidth: 0 }}
|
||||||
{bubbleMetaJSX}
|
before={avatarJSX}
|
||||||
</Box>
|
header={headerJSX}
|
||||||
)}
|
onContextMenu={handleContextMenu}
|
||||||
{isBubble && !isOwnMessage && (
|
>
|
||||||
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
{msgContentJSX}
|
||||||
{msgContentJSX}
|
</BubbleLayout>
|
||||||
</BubbleLayout>
|
{bubbleMetaJSX}
|
||||||
)}
|
</Box>
|
||||||
{messageLayout !== MessageLayout.Compact && !isBubble && (
|
)}
|
||||||
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
{isBubble && !isOwnMessage && (
|
||||||
{headerJSX}
|
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</ModernLayout>
|
</BubbleLayout>
|
||||||
|
)}
|
||||||
|
{messageLayout !== MessageLayout.Compact && !isBubble && (
|
||||||
|
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||||
|
{headerJSX}
|
||||||
|
{msgContentJSX}
|
||||||
|
</ModernLayout>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
|
|
@ -1191,6 +1250,9 @@ export type EventProps = {
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
showDeveloperTools?: boolean;
|
showDeveloperTools?: boolean;
|
||||||
|
streamRailStart?: boolean;
|
||||||
|
streamRailEnd?: boolean;
|
||||||
|
isStream?: boolean;
|
||||||
};
|
};
|
||||||
export const Event = as<'div', EventProps>(
|
export const Event = as<'div', EventProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -1203,6 +1265,9 @@ export const Event = as<'div', EventProps>(
|
||||||
messageSpacing,
|
messageSpacing,
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
showDeveloperTools,
|
showDeveloperTools,
|
||||||
|
streamRailStart,
|
||||||
|
streamRailEnd,
|
||||||
|
isStream = false,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -1241,7 +1306,7 @@ export const Event = as<'div', EventProps>(
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={messageSpacing}
|
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
||||||
autoCollapse
|
autoCollapse
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor}
|
selected={!!menuAnchor}
|
||||||
|
|
@ -1328,7 +1393,11 @@ export const Event = as<'div', EventProps>(
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div onContextMenu={handleContextMenu}>{children}</div>
|
<IsStreamProvider
|
||||||
|
value={{ enabled: isStream, railStart: streamRailStart, railEnd: streamRailEnd }}
|
||||||
|
>
|
||||||
|
<div onContextMenu={handleContextMenu}>{children}</div>
|
||||||
|
</IsStreamProvider>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,11 @@ import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
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 { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
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() {
|
function SelectMessageSpacing() {
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
|
|
@ -695,10 +625,11 @@ function Messages() {
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">{t('Settings.messages')}</Text>
|
<Text size="L400">{t('Settings.messages')}</Text>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile title={t('Settings.message_layout')} after={<SelectMessageLayout />} />
|
<SettingTile
|
||||||
</SequenceCard>
|
title={t('Settings.message_spacing')}
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
description={t('Settings.message_spacing_dm_note')}
|
||||||
<SettingTile title={t('Settings.message_spacing')} after={<SelectMessageSpacing />} />
|
after={<SelectMessageSpacing />}
|
||||||
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<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,
|
getSpaceRoomPath,
|
||||||
} from '../pages/pathUtils';
|
} from '../pages/pathUtils';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getOrphanParents, guessPerfectParent } from '../utils/room';
|
import { getOrphanParents, guessPerfectParent, isDirectStreamRoom } from '../utils/room';
|
||||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
|
|
@ -88,7 +88,16 @@ export const useRoomNavigate = () => {
|
||||||
return;
|
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);
|
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,29 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
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 }) {
|
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const rooms = useDirectRooms();
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
||||||
const { roomIdOrAlias, eventId } = useParams();
|
const { roomIdOrAlias, eventId } = useParams();
|
||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
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} />;
|
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParams
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
import { getDirectRoomPath } from '../../pathUtils';
|
import { getDirectRoomPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||||
import { isDirectInvite } from '../../../utils/room';
|
import { isDirectStreamRoom } from '../../../utils/room';
|
||||||
|
|
||||||
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
@ -23,15 +23,11 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
// Cold-start push routing lands on /home/{roomId} (sw.ts has no access to
|
// 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
|
// mDirectAtom). For DM rooms we redirect to /direct/. The shared four-source
|
||||||
// from useEffect so it can still be empty on the first frame after a fresh
|
// predicate matches the render-side `useIsDirectStream` so the redirect
|
||||||
// invite — fall back to the synchronous SDK signals (invite-state DMInviter
|
// decision and the Stream layout decision agree on the first paint instead
|
||||||
// or m.room.member content with is_direct: true) so the redirect lands on
|
// of one frame later. See plan §6.5 / §6.7.
|
||||||
// the first paint instead of one frame later. See plan §6.5 / §6.7.
|
if (room && isDirectStreamRoom(mx, room, mDirects)) {
|
||||||
if (
|
|
||||||
room &&
|
|
||||||
(mDirects.has(room.roomId) || !!room.getDMInviter() || isDirectInvite(room, mx.getUserId()))
|
|
||||||
) {
|
|
||||||
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
|
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;
|
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 => {
|
export const isSpace = (room: Room | null): boolean => {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue