From 0f882567c5c1ad45b8dffd15037caccc89d2ed4c Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 29 May 2026 23:45:18 +0300 Subject: [PATCH] feat(room): rework the 1:1 DM timeline into a VS Code-style rail with bold author labels, bubble-less own messages and same-sender run grouping --- src/app/components/RenderMessageContent.tsx | 17 +- .../message/attachment/StreamMedia.css.ts | 44 ---- .../message/attachment/StreamMediaImage.tsx | 29 +-- .../message/attachment/StreamMediaShell.tsx | 54 +---- .../message/attachment/StreamMediaVideo.tsx | 20 +- .../components/message/layout/Channel.css.ts | 9 +- src/app/components/message/layout/Stream.tsx | 109 ++++++--- .../components/message/layout/layout.css.ts | 226 +++++++++++------- src/app/features/room/RoomTimeline.tsx | 122 +++++++--- src/app/features/room/RoomView.css.ts | 10 + src/app/features/room/RoomView.tsx | 8 +- src/app/features/room/message/CallMessage.tsx | 18 +- src/app/features/room/message/Message.tsx | 65 ++--- .../features/room/message/SyslineMessage.tsx | 55 ++--- src/app/hooks/useDotColor.ts | 71 +++--- src/app/hooks/useMemberPowerTag.ts | 30 ++- src/index.css | 29 ++- 17 files changed, 503 insertions(+), 413 deletions(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 45f9aaf5..79a756ef 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, createContext, useContext } from 'react'; +import React, { createContext, useContext } from 'react'; import { MsgType } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; import { Opts } from 'linkifyjs'; @@ -43,11 +43,10 @@ import { logMedia } from './message/attachment/streamMediaDebug'; // in the timeline; pin-menu / message-search leave it null and fall back // to the legacy MImage / MVideo Attachment chrome. export type StreamMediaContextValue = { + // Only `own` survives — it drives the bubble's asymmetric notch corner. The + // sender nick used to be overlaid on the media via this context, but it's + // now rendered ABOVE the media by the Stream name header (like text). own: boolean; - username: string; - senderId: string; - onUsernameClick: MouseEventHandler; - onUsernameContextMenu: MouseEventHandler; }; export const StreamMediaContext = createContext(null); export const useStreamMediaContext = (): StreamMediaContextValue | null => @@ -237,10 +236,6 @@ export function RenderMessageContent({ ) : ( @@ -288,10 +283,6 @@ export function RenderMessageContent({ diff --git a/src/app/components/message/attachment/StreamMedia.css.ts b/src/app/components/message/attachment/StreamMedia.css.ts index e0a48daf..831dba6f 100644 --- a/src/app/components/message/attachment/StreamMedia.css.ts +++ b/src/app/components/message/attachment/StreamMedia.css.ts @@ -45,50 +45,6 @@ export const StreamMediaBubble = recipe({ }, }); -// Username chip — anchored top-left so its text baseline lands on the -// rail-dot baseline, matching the Username header in text bubbles. -// StreamMediaBubble has no real border (frame is a pseudo-element above -// the image), so the chip's coordinate space is flush with the bubble's -// outer edge — no off-by-one compensation needed. -export const StreamMediaUsernameOverlay = style({ - position: 'absolute', - top: config.space.S200, - left: config.space.S200, - maxWidth: `calc(100% - ${config.space.S400})`, - zIndex: 2, - // Wrapper is decorative — clicks pass through to the image. The - // - - )} {children} ); diff --git a/src/app/components/message/attachment/StreamMediaVideo.tsx b/src/app/components/message/attachment/StreamMediaVideo.tsx index 37b5e950..a67419fc 100644 --- a/src/app/components/message/attachment/StreamMediaVideo.tsx +++ b/src/app/components/message/attachment/StreamMediaVideo.tsx @@ -1,4 +1,4 @@ -import React, { MouseEventHandler, ReactNode } from 'react'; +import React, { ReactNode } from 'react'; import { IVideoContent, MATRIX_SPOILER_PROPERTY_NAME, @@ -11,10 +11,6 @@ import { StreamMediaShell } from './StreamMediaShell'; export type StreamMediaVideoProps = { content: IVideoContent; own: boolean; - overlay?: ReactNode; - onUsernameClick?: MouseEventHandler; - onUsernameContextMenu?: MouseEventHandler; - senderId?: string; renderAsFile: () => ReactNode; renderVideoContent: (props: RenderVideoContentProps) => ReactNode; }; @@ -22,10 +18,6 @@ export type StreamMediaVideoProps = { export function StreamMediaVideo({ content, own, - overlay, - onUsernameClick, - onUsernameContextMenu, - senderId, renderAsFile, renderVideoContent, }: StreamMediaVideoProps) { @@ -42,15 +34,7 @@ export function StreamMediaVideo({ } return ( - + {renderVideoContent({ body: content.body || 'Video', info: videoInfo, diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts index d1359e65..81387520 100644 --- a/src/app/components/message/layout/Channel.css.ts +++ b/src/app/components/message/layout/Channel.css.ts @@ -171,15 +171,16 @@ globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessage globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, { borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, - // Peer (not-own) bubble bg — matches Stream layout's `peerBg` - // variant. Covers channels main timeline AND thread drawer + // Peer (not-own) bubble bg for the channel layout — its own var. (The 1-1 + // Stream layout's incoming bubble instead binds to color.Surface.Container, + // the composer surface.) Covers channels main timeline AND thread drawer // (both pass `headerInBubble`, so `data-bubble="true"` fires). backgroundColor: 'var(--vojo-peer-bubble-bg)', }); // Small gap so the in-bubble header (username + time) doesn't sit flush -// against the first line of message text. Matches `StreamBubbleHeader`'s -// 2px gap. +// against the first line of message text. Matches the Stream layout's +// `StreamName` 2px marginBottom. globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, { marginBottom: toRem(2), }); diff --git a/src/app/components/message/layout/Stream.tsx b/src/app/components/message/layout/Stream.tsx index 7fea81fe..9a8ec183 100644 --- a/src/app/components/message/layout/Stream.tsx +++ b/src/app/components/message/layout/Stream.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, useImperativeHandle, useRef } from 'react'; import classNames from 'classnames'; -import { as } from 'folds'; +import { as, toRem } from 'folds'; import * as css from './layout.css'; import { useStreamLayoutDebug } from './streamDebug'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; @@ -19,7 +19,18 @@ export const STREAM_MESSAGE_SPACING = '400' as const; // chat. The auto-sized grid column then matches the surrounding message rows. const DAY_DIVIDER_PLACEHOLDER_TS = 0; -// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b). +// Rail-dot diameters. The base dot is 9px (see `StreamDotSize` / +// `StreamDotColumn` in layout.css.ts, which keep the rail X consistent). The +// neutral gray dot is 0.95× that; the «state» dots (green = read, gold = +// mention, red = failed — `dotProminent`) are 1.1× the neutral so they read as +// slightly larger on the rail. The dot stays in-flow, so a prominent dot just +// overflows the 9px column by ~0.4px into the gap (centred enough to read on +// the rail) — same harmless trick the larger day-dot already uses. +const STREAM_DOT_NEUTRAL = toRem(8.55); +const STREAM_DOT_PROMINENT = toRem(9.405); + +// Stream layout — DM «VS Code chat» redesign +// (docs/plans/dm_stream_vscode_redesign.md). // // Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts): // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐ @@ -34,15 +45,30 @@ export type StreamLayoutProps = { time?: ReactNode; dotColor: string; dotOpacity: number; + // `true` → green/gold/red «state» dot drawn 1.1× the neutral gray size. + dotProminent?: boolean; + // Drives the bubble chrome: own → plain text on the chat background (no + // bubble); incoming → filled bubble (composer-matched surface). See + // layout.css.ts `StreamBubble`. isOwn?: boolean; - // Peer (not-own) bubble bg — caller passes `!isOwn` so every - // «чужое» сообщение reshades to `--vojo-peer-bubble-bg`. Applies - // in 1-1 DMs, groups, channels alike. No effect for own messages. - peerBg?: boolean; compact?: boolean; + // Same-sender continuation row (the whole run after the first message, any + // minute): drop the rail dot + timestamp + nick and stack the body tight + // under the previous one. The timestamp is kept in the DOM (invisible) only + // to reserve the time-track width. The caller also passes `header={undefined}` + // for collapsed rows. See RoomTimeline `collapsed`. + collapsed?: boolean; + // Author name — rendered as a bold label ABOVE the bubble, on the + // dot/timestamp baseline (DM «VS Code chat» redesign). `undefined` for + // media rows (the name is overlaid on the media instead) and for collapsed + // continuation rows. header?: ReactNode; railStart?: boolean; railEnd?: boolean; + // Suppress the rail segment entirely on this row. Set for trailing + // continuation rows that sit AFTER the last dot (the rail must stop at the + // last dot, not bleed down through the dot-less tail of a run). + railHidden?: boolean; // Image messages: bubble bg/border/padding collapse so the // StreamMediaImage child supplies the visible chrome. mediaMode?: boolean; @@ -106,12 +132,14 @@ export const StreamLayout = as<'div', StreamLayoutProps>( time, dotColor, dotOpacity, + dotProminent, isOwn, - peerBg, compact, + collapsed, header, railStart, railEnd, + railHidden, mediaMode, reactions, threadSummary, @@ -145,45 +173,70 @@ export const StreamLayout = as<'div', StreamLayoutProps>( return (
- + {/* Collapsed rows keep the timestamp in the DOM (so the auto-sized + time track stays the same width and the body column doesn't shift) + but hide it — only the first message of the minute shows its time. */} + {time} - - + {/* The rail is suppressed entirely on trailing continuation rows + (after the last dot) so the line stops at the last dot instead of + bleeding down through the dot-less tail of a run. */} + {!railHidden && ( - + )} + {/* No dot on collapsed continuation rows — the rail passes straight + through, anchored by the first message's dot above. */} + {!collapsed && ( + + + + )}
+ {header && ( +
+ {header} +
+ )}
- {header && ( -
- {header} -
- )} {children}
{threadSummary &&
{threadSummary}
} diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 36eba0ad..34f5d719 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -138,7 +138,8 @@ export const UsernameBold = style({ fontWeight: 550, }); -// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b). +// Stream layout (DM «VS Code chat» redesign — see +// docs/plans/dm_stream_vscode_redesign.md). // // Symmetric three-gap layout, expressed as a 3-track CSS grid: // @@ -168,18 +169,18 @@ export const UsernameBold = style({ // loosen on mobile without disturbing the screen-edge anchor (which the // user dialled in earlier and asked to keep). // -// Mobile: pad = S100 (minimal screen-edge anchor — already at the limit, -// dropping further would push timestamp glyphs flush against the screen -// edge); gap = 2 × S100 = S200 (the user asked to double the inter-element -// gap on native). -// Desktop: pad = S400 (16px ≈ 0.42 cm — shifted ~4px / 1mm closer to the -// PageNav per user request, the column still clears the nav rail); gap = -// S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element -// gap by 1.1× — keeps the layout tighter without dropping a whole token). +// The whole time→dot→nick block was nudged ~1mm (~4px) to the right per the +// latest request — both pad values stepped up one token. +// Mobile: pad = S200 (8px — was S100; +4px ≈ 1mm off the screen edge); gap = +// S200 (the user asked to double the inter-element gap on native). +// Desktop: pad = S500 (20px — was S400; +4px ≈ 1mm further from the PageNav, +// the column still clears the nav rail); gap = S500 / 1.1 ≈ 18.2 px (the user +// asked to shrink the desktop inter-element gap by 1.1× — keeps the layout +// tighter without dropping a whole token). const StreamRowPadVar = createVar(); const StreamRowGapVar = createVar(); -const StreamRowPadMobile = config.space.S100; -const StreamRowPadDesktop = config.space.S400; +const StreamRowPadMobile = config.space.S200; +const StreamRowPadDesktop = config.space.S500; const StreamRowGapMobile = config.space.S200; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; @@ -193,13 +194,22 @@ const StreamBubbleBorderWidth = '1px'; const StreamTimeLineHeight = toRem(13); const StreamRailBridgeY = config.space.S400; -// Vertical centre of the bubble's header text from the row's content-area -// top. = bubble.borderTop (1) + bubble.paddingTop (S200) + line.T200 / 2. -// Used to push the timestamp and the dot down in their grid cells so -// they read on the same baseline as the Username component inside the -// bubble. Rail-end's height also adds `S100` back to account for the -// rail's negative `top` offset (it starts above the row outer edge). -const StreamHeaderInnerCenterY = `calc(${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`; +// Author name (header line) — DM «VS Code chat» redesign +// (docs/plans/dm_stream_vscode_redesign.md). Bold, pure white/black, a step +// larger than chat body (T400 = 15px). The dot + timestamp vertically centre +// on this line, so its line-height drives the rail geometry below. +const StreamNameFontSize = toRem(16); +const StreamNameLineHeight = toRem(20); + +// Vertical centre of the author-name line, measured from the row's +// content-area top. The name is now the FIRST child of StreamColumn (track 3) +// with nothing above it, so the centre is simply half its line height — the +// old in-bubble offset (border + padding) no longer applies. The timestamp + +// dot are pushed down by this amount (minus their own half-heights) so all +// three read on one baseline. Rail-end/start heights resolve against it; each +// adds `S100` back to account for the rail's negative `top` offset (it starts +// above the row outer edge). +const StreamHeaderInnerCenterY = `calc(${StreamNameLineHeight} / 2)`; export const StreamRoot = recipe({ base: { @@ -242,8 +252,18 @@ export const StreamRoot = recipe({ paddingRight: 0, }, }, + // Same-minute continuation row (dot/name/time hidden): tighten the stack. + // Drop the top padding and pull up by S100, so collapsed bodies sit ~7px + // apart vs the ~14px gap between distinct minute groups. The rail bridge + // (±S400) dwarfs this, so the rail line stays unbroken through the cluster. + collapsed: { + true: { + paddingTop: 0, + marginTop: `calc(-1 * ${config.space.S100})`, + }, + }, }, - defaultVariants: { compact: false }, + defaultVariants: { compact: false, collapsed: false }, }); // Sysline timestamp. Now a regular grid item in track 1 — sized to its @@ -421,106 +441,134 @@ export const StreamThreadSummary = style({ export const StreamBubble = recipe({ base: { + // Incoming (peer) bubble — filled with the SAME surface as the composer + // card (`color.Surface.Container`, see RoomView.css.ts) so the bubble and + // the input form read as one material; it sits a step darker than the + // `SurfaceVariant.Container` page background. Top-left corner flat, other + // three rounded (user point 7). Own messages override to a no-chrome + // plain block below. backgroundColor: color.Surface.Container, color: color.SurfaceVariant.OnContainer, border: `${StreamBubbleBorderWidth} solid ${color.Surface.ContainerLine}`, - paddingTop: config.space.S200, - paddingBottom: config.space.S200, + borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, + // Padding bumped ~1.1× (user request) so the bubble reads a touch larger + // around the text: vertical 8→8.8px, horizontal 15→16.5px. + paddingTop: toRem(8.8), + paddingBottom: toRem(8.8), + paddingLeft: toRem(16.5), + paddingRight: toRem(16.5), minWidth: 0, maxWidth: toRem(720), position: 'relative', zIndex: 1, }, variants: { - // Asymmetric notch — own: bottom-left flat, three corners R500+. - // Incoming: top-left flat, three corners R500+. Mirrored on the - // vertical axis so own/peer read as opposing silhouettes. + // Own messages render as plain text on the chat background — no fill, + // no border, no rounding, flush-left beneath the author name and + // spanning the message column like a paragraph (user points 2 + 4). own: { true: { - borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`, - }, - false: { - borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, + backgroundColor: 'transparent', + border: 'none', + borderRadius: 0, + paddingLeft: 0, + paddingRight: 0, + paddingTop: 0, + paddingBottom: 0, + display: 'block', + width: '100%', + maxWidth: '100%', }, + false: {}, }, - // Both breakpoints fit content (inline-block + fit-content + max-width - // 100% of the message column). Per user feedback бабл должен быть «по - // размеру текстового сообщения», not stretched to the column's right - // edge on mobile. Padding still tightens on mobile (S300 vs 15px) to - // keep the bubble visually compact on narrow viewports. - compact: { - true: { + // Placeholder so the compound variants below can target the breakpoint. + compact: { true: {}, false: {} }, + // Image / video: bubble becomes a transparent shell so the + // StreamMediaImage child supplies the visible chrome. `display: block, + // width: 100%` (NOT fit-content) so the child's `max-width: 100%` has a + // definite width to clamp against — fit-content would make the chain + // circular and overflow on narrow screens. + mediaMode: { true: {} }, + }, + // Compounds are emitted after the variant classes, so they win the cascade. + compoundVariants: [ + // Peer bubble width — fit the text on BOTH web/desktop and native/mobile + // (the user settled on «по размеру текста» everywhere; this supersedes the + // earlier native-stretch tweak). Mobile only tightens the horizontal + // padding for narrow viewports. + { + variants: { own: false, compact: false, mediaMode: false }, + style: { display: 'inline-block', width: 'fit-content', maxWidth: '100%' }, + }, + { + variants: { own: false, compact: true, mediaMode: false }, + style: { display: 'inline-block', width: 'fit-content', maxWidth: '100%', - paddingLeft: config.space.S300, - paddingRight: config.space.S300, - }, - false: { - display: 'inline-block', - width: 'fit-content', - maxWidth: '100%', - paddingLeft: toRem(15), - paddingRight: toRem(15), + // Tighter horizontal padding on narrow viewports (12 × 1.1 ≈ 13.2px). + paddingLeft: toRem(13.2), + paddingRight: toRem(13.2), }, }, - // Peer (not-own) bubble bg — differentiation between «я» and - // «не я» across every room class. Media rows neutralize this via - // the `peerBg + mediaMode` compound below (order-independent). - peerBg: { - true: { - backgroundColor: 'var(--vojo-peer-bubble-bg)', - }, - }, - // Image messages: bubble becomes a transparent shell so the - // StreamMediaImage child supplies the visible chrome instead. - // `display: block, width: 100%` (NOT fit-content) so the bubble has a - // definite width inherited from StreamColumn — required for the - // child's `max-width: 100%` to clamp the image. With fit-content the - // chain becomes circular (parent shrinks to child, child grows to - // its explicit pixel width), and the image overflows past the - // viewport on narrow screens. - mediaMode: { - true: { + // Media shell wins over both the peer fill and the width rules above + // (incl. own's lifted max-width) — keeps image/video bubbles capped at + // the same 720px as before regardless of own/peer. + { + variants: { mediaMode: true }, + style: { backgroundColor: 'transparent', border: 'none', borderRadius: 0, padding: 0, display: 'block', width: '100%', + maxWidth: toRem(720), }, }, - }, - // Compound overrides — emitted after all variant classes, so they - // win the cascade regardless of variant declaration order. Keeps - // peer image / video bubbles transparent (the StreamMediaImage - // child supplies the chrome) even though `peerBg` would otherwise - // paint `--vojo-peer-bubble-bg` underneath. - compoundVariants: [ - { - variants: { peerBg: true, mediaMode: true }, - style: { backgroundColor: 'transparent' }, - }, ], defaultVariants: { own: false, compact: false, - peerBg: false, mediaMode: false, }, }); -export const StreamBubbleHeader = style({ +// Author name — sits ABOVE the bubble as the first child of StreamColumn +// (track 3), aligned on the dot/timestamp baseline. Bold, a step larger than +// chat body, pure white (dark) / black (light) via `--vojo-stream-name`. The +// inner Username button inherits colour/size/weight from here, so callers +// pass the name without their own colour/size. +export const StreamName = style({ position: 'relative', - marginBottom: toRem(2), - fontSize: toRem(11), - lineHeight: config.lineHeight.T200, - minHeight: config.lineHeight.T200, - fontWeight: 600, + // Symmetric top-left corner (user request): the vertical gap from the nick + // down to the bubble equals the horizontal gap from the bubble's left edge + // out to the timerail = column-gap + dot radius. Inherits StreamRowGapVar + // from StreamRoot, so it tracks the per-breakpoint gap. Only applies on run + // heads (continuations render no nick), so continuations stay tight. + marginBottom: `calc(${StreamRowGapVar} + (${StreamDotSize} / 2))`, + fontSize: StreamNameFontSize, + lineHeight: StreamNameLineHeight, + minHeight: StreamNameLineHeight, + fontWeight: 700, + color: 'var(--vojo-stream-name)', display: 'flex', alignItems: 'center', gap: toRem(6), flexWrap: 'nowrap', + minWidth: 0, + // Bound the label to the message column so a long display name truncates + // (ellipsis) instead of overflowing the rail. StreamColumn uses + // `align-items: flex-start`, so without this the flex container would + // content-size past the column edge. + maxWidth: '100%', +}); + +// Let the (single) name child shrink so css.Username's ellipsis engages on +// long display names — replaces the old `` wrapper. Must be a +// globalStyle: vanilla-extract `style()` selectors may only target `&`. +globalStyle(`${StreamName} > *`, { + minWidth: 0, }); // Message-row timestamp — grid item in track 1, content-sized. Pushed @@ -545,6 +593,13 @@ globalStyle(`${StreamHeaderTime} time`, { lineHeight: StreamTimeLineHeight, }); +// Collapsed continuation rows keep the timestamp in the DOM so the auto-sized +// time track stays the same width (body column doesn't shift) but render it +// invisible — a same-sender run shows its dot+timestamp only on the first row. +export const StreamHeaderTimeHidden = style({ + visibility: 'hidden', +}); + // Sysline — thin single-line state-event row inside Stream layout. // Composes with StreamRoot so the time / dot / content tracks line up // vertically with message rows above and below. Override align-items to @@ -556,12 +611,15 @@ export const StreamSysline = style({ paddingBottom: toRem(2), }); +// System lines (room name/topic/avatar changes, hidden-event dev rows) read +// as muted, understated «Thinking»-style notes (user point 10) — soft grey, +// italic, in the regular sans (not the mono timestamp face) so they recede +// behind real messages instead of reading as another bubble. export const StreamSyslineBody = style({ - fontSize: toRem(11.5), + fontSize: toRem(12), color: color.Surface.OnContainer, - opacity: 0.55, - fontFamily: - '"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + opacity: 0.5, + fontStyle: 'italic', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bb5b1d23..b72e21ed 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -1499,7 +1499,8 @@ export function RoomTimeline({ }, [timeline]); const renderMatrixEvent = useMatrixEventRenderer< - [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean] + // (mEventId, mEvent, item, timelineSet, collapse, railStart, railEnd, railHidden) + [string, MatrixEvent, number, EventTimelineSet, boolean, boolean, boolean, boolean] >( { // Suppress DM-call service events from the timeline. In encrypted @@ -1516,7 +1517,8 @@ export function RoomTimeline({ timelineSet, collapse, streamRailStart, - streamRailEnd + streamRailEnd, + railHidden ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); @@ -1541,6 +1543,7 @@ export function RoomTimeline({ room={room} mEvent={mEvent} collapse={collapse} + railHidden={railHidden} highlight={highlighted} edit={editId === mEventId} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} @@ -1622,7 +1625,8 @@ export function RoomTimeline({ timelineSet, collapse, streamRailStart, - streamRailEnd + streamRailEnd, + railHidden ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); @@ -1656,6 +1660,7 @@ export function RoomTimeline({ room={room} mEvent={mEvent} collapse={collapse} + railHidden={railHidden} highlight={highlighted} edit={editId === mEventId} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} @@ -1780,7 +1785,8 @@ export function RoomTimeline({ timelineSet, collapse, streamRailStart, - streamRailEnd + streamRailEnd, + railHidden ) => { const reactionRelations = getEventReactions(timelineSet, mEventId); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); @@ -1796,6 +1802,7 @@ export function RoomTimeline({ room={room} mEvent={mEvent} collapse={collapse} + railHidden={railHidden} highlight={highlighted} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} @@ -2125,7 +2132,10 @@ export function RoomTimeline({ ); let prevEvent: MatrixEvent | undefined; - let isPrevRendered = false; + // The last event that actually RENDERED (skips hidden membership / reaction / + // edit / redaction / RTC rows). `sameSenderRun` compares against this so an + // intervening hidden event doesn't break a same-sender run. + let prevRenderedEvent: MatrixEvent | undefined; let newDivider = false; let dayDivider = false; @@ -2206,19 +2216,32 @@ export function RoomTimeline({ 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. + // Whether `cur` continues `prev` as the same Stream «run» (so `cur` is a + // dot-less continuation, not a rail head). Mirrors the render loop's + // `sameSenderRun` for the STREAM layout (no minute split): same sender + + // type, same day, and not across the unread boundary (unless it's our own + // message). Used only to place the rail endpoints — the channel layout has + // no rail, so the absence of its 2-minute split here doesn't matter. + const isStreamRunContinuation = (prev: MatrixEvent, cur: MatrixEvent): boolean => + prev.getSender() === cur.getSender() && + prev.getType() === cur.getType() && + inSameDay(prev.getTs(), cur.getTs()) && + (prev.getId() !== readUptoEventIdRef.current || cur.getSender() === mx.getUserId()); + + // Single forward pass that records, for each visible item, whether there is + // any RENDERABLE event before it (→ Stream rail-start = no renderable before) + // and finds the LAST run head (last dot). Looks at renderability — skipping + // the reaction / edit / hidden service events that render as nothing — so a + // hidden row between two messages doesn't reset rail-start mid-thread. The + // rail must stop at the last dot, so the rail-end caps on the last head and + // the trailing dot-less continuations below it suppress their rail (see + // `streamRailHidden`). const { before: streamRenderableItemHasBefore, - after: streamRenderableItemHasAfter, hasRenderable: hasRenderableEvent, + lastHead: streamLastHeadItem, } = (() => { const before = new Map(); - const after = new Map(); const items = getItems(); const renderableFlags = items.map((item) => { @@ -2226,19 +2249,26 @@ export function RoomTimeline({ return !!ev && isRenderableTimelineEvent(ev); }); + // A renderable row is a «head» (renders a dot) when it does NOT continue + // the previous renderable row's run; track the last one for the rail-end. let seenBefore = false; + let prevRenderable: MatrixEvent | undefined; + let lastHead: number | undefined; for (let index = 0; index < items.length; index += 1) { before.set(items[index], seenBefore); - if (renderableFlags[index]) seenBefore = true; + if (renderableFlags[index]) { + seenBefore = true; + const ev = getTimelineItemEvent(items[index]); + if (ev) { + const isHead = + prevRenderable === undefined || !isStreamRunContinuation(prevRenderable, ev); + if (isHead) lastHead = items[index]; + prevRenderable = ev; + } + } } - 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, hasRenderable: renderableFlags.some(Boolean) }; + return { before, hasRenderable: renderableFlags.some(Boolean), lastHead }; })(); const eventRenderer = (item: number) => { @@ -2265,27 +2295,50 @@ export function RoomTimeline({ dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; } + // Same-sender «run»: the previous rendered row is the same author + type, + // not across a day/unread divider. Only the FIRST message of a run is a + // rail «head» (dot + timestamp + nick); every later message is a collapsed + // continuation (a bare body, new paragraph) until the other side replies + // or a day/unread divider breaks the run. + // Compare against the last RENDERED message, not the immediately preceding + // timeline item: in a 1:1 several event types render as nothing (hidden + // membership / profile changes, reactions, edits, redactions, RTC service + // events), and any of them sitting between two of the peer's messages must + // NOT break the run. `isStreamRunContinuation` is the same predicate the + // rail pre-scan uses, so the dots and the collapse stay in agreement. + const sameSenderRun = + prevRenderedEvent !== undefined && isStreamRunContinuation(prevRenderedEvent, mEvent); + + // Stream collapses the WHOLE run (drop dot + time + nick, stack the body + // tight) regardless of minute — the series ends only when the sender + // changes. The channel layout keeps its looser ~90s avatar/header grouping, + // so it still gates on a 2-minute gap. const collapsed = - isPrevRendered && - !dayDivider && - (!newDivider || eventSender === mx.getUserId()) && - prevEvent !== undefined && - prevEvent.getSender() === eventSender && - prevEvent.getType() === mEvent.getType() && - minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + sameSenderRun && + (messageLayout !== 'channel' || + (prevRenderedEvent !== undefined && + minuteDifference(prevRenderedEvent.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 + // before me in the visible window» — not at the immediately preceding + // item, which is non-rendered 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 = rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false; + // The rail stops at the LAST dot: cap the rail-end on the last run head + // (not the last renderable row) and suppress the rail on the trailing + // dot-less continuations below it. Only meaningful at the live end; when + // more history sits below the window the rail extends as before. + const atLiveEnd = liveTimelineLinked && rangeAtEnd; const streamRailEnd = - liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true; + atLiveEnd && streamLastHeadItem !== undefined && item === streamLastHeadItem; + const streamRailHidden = + atLiveEnd && streamLastHeadItem !== undefined && item > streamLastHeadItem; // Channels-mode renderer gate — same helper as the predicate above so // the rail-endpoint scan and the renderer never disagree on visibility. @@ -2303,10 +2356,11 @@ export function RoomTimeline({ timelineSet, collapsed, streamRailStart, - streamRailEnd + streamRailEnd, + streamRailHidden ); prevEvent = mEvent; - isPrevRendered = !!eventJSX; + if (eventJSX) prevRenderedEvent = mEvent; if (newDivider && eventJSX) { // TODO(P3c-followup): replace the legacy full-width unread divider with diff --git a/src/app/features/room/RoomView.css.ts b/src/app/features/room/RoomView.css.ts index a175701f..53f4989b 100644 --- a/src/app/features/room/RoomView.css.ts +++ b/src/app/features/room/RoomView.css.ts @@ -20,6 +20,16 @@ import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; // same Android-WebView stuck-:hover suppression. export const ChatComposer = style({}); +// Desktop web only: constrain the composer card to ~3/4 of the chat pane +// width and centre it, instead of spanning edge-to-edge (user point 15). +// Applied conditionally in RoomView.tsx via `useScreenSizeContext` so native +// / mobile / tablet keep the full-width card the user is happy with. +export const ComposerDesktopClamp = style({ + maxWidth: '75%', + marginLeft: 'auto', + marginRight: 'auto', +}); + // Outer absolute-positioned wrapper for the composer overlay. Carries the // slide/fade transition driven by the `data-hidden` attribute set from // React state. CSS class (not inline `transition`) so the diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index c31f552f..67a74afc 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -3,6 +3,8 @@ import { Box, Text, color, config, toRem } from 'folds'; import { EventType } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { isKeyHotkey } from 'is-hotkey'; +import classNames from 'classnames'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; @@ -89,6 +91,10 @@ export function RoomView({ eventId }: { eventId?: string }) { // `TimelineRenderingType.Thread` context. const threadDrawerOpen = useThreadDrawerOpen(); + // Desktop web: centre the composer at ~3/4 width (user point 15). Native / + // mobile / tablet keep the full-width card. + const isDesktop = useScreenSizeContext() === ScreenSize.Desktop; + useEffect(() => { const el = composerWrapRef.current; if (!el) { @@ -171,7 +177,7 @@ export function RoomView({ eventId }: { eventId?: string }) { onFocusCapture={() => setComposerHidden(false)} >
1` the bubble represents a chain of retries that @@ -172,13 +173,10 @@ export function CallMessage({ ); - const streamHeader = ( - - - {isOwnMessage ? t('Direct.message_me_label') : senderName} - - - ); + // No inline colour/size — the `StreamName` wrapper supplies the bold, + // pure white/black, larger-than-body styling that the name inherits. Own + // events show the user's own nick (display name), not a «me» label. + const streamHeader = {senderName}; return ( } dotColor={dot.color} dotOpacity={dot.opacity} + dotProminent={dot.prominent} isOwn={isOwnMessage} - peerBg={peerBg} compact={isMobile} railStart={streamRailStart} railEnd={streamRailEnd} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ec67167b..5c22eb5e 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -656,6 +656,11 @@ export type MessageProps = { room: Room; mEvent: MatrixEvent; collapse: boolean; + // Stream layout only: suppress the rail segment on this row — set for a + // trailing continuation that sits after the last dot, so the rail stops at + // the last dot instead of bleeding down the dot-less tail of a run. Ignored + // by the channel layout. + railHidden?: boolean; highlight: boolean; edit?: boolean; canDelete?: boolean; @@ -727,6 +732,7 @@ const MessageInner = as<'div', MessageProps>( room, mEvent, collapse, + railHidden, highlight, edit, canDelete, @@ -779,10 +785,11 @@ const MessageInner = as<'div', MessageProps>( const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; - const dot = useDotColor(room, mEvent, true, hideReadReceipts); + // Only the Stream layout renders the rail dot — skip the push-action eval + // and Receipt subscription on channel-layout rows where it is discarded. + const dot = useDotColor(room, mEvent, layout !== 'channel', hideReadReceipts); const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; - const peerBg = !isOwnMessage; // msgType comes from the parent — RoomTimeline reads // `mEvent.getContent().msgtype` synchronously and re-evaluates inside @@ -806,28 +813,11 @@ const MessageInner = as<'div', MessageProps>( const streamMediaCtx = useMemo( () => - // Stream-only: the overlay username on top of media collapses the - // bubble's header slot. Channel layout renders username above the - // image normally, so the overlay is suppressed (`null` ctx). - mediaMode && layout === 'stream' - ? { - own: isOwnMessage, - username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName, - senderId, - onUsernameClick, - onUsernameContextMenu: onUserClick, - } - : null, - [ - mediaMode, - layout, - isOwnMessage, - senderDisplayName, - senderId, - onUsernameClick, - onUserClick, - t, - ] + // Media chrome only needs `own` (for the bubble's notch corner). The + // sender nick is rendered ABOVE the media by the Stream name header + // now (like text) — there's no overlay on the image any more. + mediaMode && layout === 'stream' ? { own: isOwnMessage } : null, + [mediaMode, layout, isOwnMessage] ); const msgContentJSX = ( @@ -1215,28 +1205,38 @@ const MessageInner = as<'div', MessageProps>( time={