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

This commit is contained in:
heaven 2026-05-29 23:45:18 +03:00
parent 153860bb38
commit 0f882567c5
17 changed files with 503 additions and 413 deletions

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, createContext, useContext } from 'react'; import React, { createContext, useContext } from 'react';
import { MsgType } from 'matrix-js-sdk'; import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser'; import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs'; 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 // in the timeline; pin-menu / message-search leave it null and fall back
// to the legacy MImage / MVideo Attachment chrome. // to the legacy MImage / MVideo Attachment chrome.
export type StreamMediaContextValue = { 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; own: boolean;
username: string;
senderId: string;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu: MouseEventHandler<HTMLButtonElement>;
}; };
export const StreamMediaContext = createContext<StreamMediaContextValue | null>(null); export const StreamMediaContext = createContext<StreamMediaContextValue | null>(null);
export const useStreamMediaContext = (): StreamMediaContextValue | null => export const useStreamMediaContext = (): StreamMediaContextValue | null =>
@ -237,10 +236,6 @@ export function RenderMessageContent({
<StreamMediaImage <StreamMediaImage
content={getContent()} content={getContent()}
own={streamMedia.own} own={streamMedia.own}
overlay={streamMedia.username}
senderId={streamMedia.senderId}
onUsernameClick={streamMedia.onUsernameClick}
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
renderImageContent={renderImageInside} renderImageContent={renderImageInside}
/> />
) : ( ) : (
@ -288,10 +283,6 @@ export function RenderMessageContent({
<StreamMediaVideo <StreamMediaVideo
content={getContent()} content={getContent()}
own={streamMedia.own} own={streamMedia.own}
overlay={streamMedia.username}
senderId={streamMedia.senderId}
onUsernameClick={streamMedia.onUsernameClick}
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
renderAsFile={renderFile} renderAsFile={renderFile}
renderVideoContent={renderVideoInside} renderVideoContent={renderVideoInside}
/> />

View file

@ -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
// <button> child opts back in via pointer-events: auto.
pointerEvents: 'none',
});
export const StreamMediaUsernameLabel = style({
display: 'inline-block',
maxWidth: '100%',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
pointerEvents: 'auto',
cursor: 'pointer',
// Plain text on the image — no fill / border / radius. text-shadow keeps
// the lavender legible against bright photo regions; saturated photos
// already contrast against Primary.OnContainer on average.
background: 'transparent',
border: 0,
padding: 0,
margin: 0,
color: color.Primary.OnContainer,
fontSize: toRem(11),
lineHeight: config.lineHeight.T200,
fontWeight: 600,
fontFamily: 'inherit',
textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)',
selectors: {
'&:hover, &:focus-visible': {
textDecoration: 'underline',
},
},
});
// Caption mini-bubble under the image. Uniform R500 corners — the asymmetric // Caption mini-bubble under the image. Uniform R500 corners — the asymmetric
// notch lives on the image-bubble itself; stacking two notch'd rectangles // notch lives on the image-bubble itself; stacking two notch'd rectangles
// reads worse than one notched + one rounded. // reads worse than one notched + one rounded.

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { import {
IImageContent, IImageContent,
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
@ -11,42 +11,23 @@ import { StreamMediaShell } from './StreamMediaShell';
// extensions) are useless as alt-text — bridged photos from // extensions) are useless as alt-text — bridged photos from
// mautrix-telegram set body to the source filename. Treat those as // mautrix-telegram set body to the source filename. Treat those as
// decorative; users with a real caption keep theirs. // decorative; users with a real caption keep theirs.
const FILENAME_ALT_RE = /^(image|img[_-].*|screenshot[_-].*|.+\.(?:jpe?g|png|webp|gif|bmp|svg|heic|avif|tiff?))$/i; const FILENAME_ALT_RE =
/^(image|img[_-].*|screenshot[_-].*|.+\.(?:jpe?g|png|webp|gif|bmp|svg|heic|avif|tiff?))$/i;
const altFor = (body?: string): string => (body && !FILENAME_ALT_RE.test(body) ? body : ''); const altFor = (body?: string): string => (body && !FILENAME_ALT_RE.test(body) ? body : '');
export type StreamMediaImageProps = { export type StreamMediaImageProps = {
content: IImageContent; content: IImageContent;
own: boolean; own: boolean;
overlay?: ReactNode;
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
senderId?: string;
renderImageContent: (props: RenderImageContentProps) => ReactNode; renderImageContent: (props: RenderImageContentProps) => ReactNode;
}; };
export function StreamMediaImage({ export function StreamMediaImage({ content, own, renderImageContent }: StreamMediaImageProps) {
content,
own,
overlay,
onUsernameClick,
onUsernameContextMenu,
senderId,
renderImageContent,
}: StreamMediaImageProps) {
const imgInfo = content.info; const imgInfo = content.info;
const mxcUrl = content.file?.url ?? content.url; const mxcUrl = content.file?.url ?? content.url;
if (typeof mxcUrl !== 'string') return null; if (typeof mxcUrl !== 'string') return null;
return ( return (
<StreamMediaShell <StreamMediaShell naturalW={imgInfo?.w} naturalH={imgInfo?.h} own={own}>
naturalW={imgInfo?.w}
naturalH={imgInfo?.h}
own={own}
overlay={overlay}
senderId={senderId}
onUsernameClick={onUsernameClick}
onUsernameContextMenu={onUsernameContextMenu}
>
{renderImageContent({ {renderImageContent({
body: altFor(content.body), body: altFor(content.body),
info: imgInfo, info: imgInfo,

View file

@ -1,10 +1,6 @@
import React, { MouseEventHandler, ReactNode, useRef } from 'react'; import React, { ReactNode, useRef } from 'react';
import { toRem } from 'folds'; import { toRem } from 'folds';
import { import { StreamMediaBubble } from './StreamMedia.css';
StreamMediaBubble,
StreamMediaUsernameLabel,
StreamMediaUsernameOverlay,
} from './StreamMedia.css';
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug'; import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
const STREAM_MEDIA_MAX_DIM = 320; const STREAM_MEDIA_MAX_DIM = 320;
@ -15,10 +11,6 @@ export type StreamMediaShellProps = {
naturalW?: number; naturalW?: number;
naturalH?: number; naturalH?: number;
own: boolean; own: boolean;
overlay?: ReactNode;
senderId?: string;
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
children: ReactNode; children: ReactNode;
}; };
@ -49,23 +41,10 @@ function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSPropert
} }
// Shared chrome for image / video timeline bubbles: square-ish bubble with // Shared chrome for image / video timeline bubbles: square-ish bubble with
// asymmetric notch + 1px pseudo-frame, and the sender username overlaid // asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
// top-left as a text chip. Caller plugs in the actual media renderer as // renderer as `children`. The sender nick is rendered ABOVE the media by the
// `children`. // Stream layout's name header (like text messages), not overlaid on the image.
// export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
// Tab order: chip rendered BEFORE children so keyboard focus visits the
// username (top-left visual) before the media tap target — matches reading
// order.
export function StreamMediaShell({
naturalW,
naturalH,
own,
overlay,
senderId,
onUsernameClick,
onUsernameContextMenu,
children,
}: StreamMediaShellProps) {
const bubbleRef = useRef<HTMLDivElement>(null); const bubbleRef = useRef<HTMLDivElement>(null);
const computedStyle = computeBoxStyle(naturalW, naturalH); const computedStyle = computeBoxStyle(naturalW, naturalH);
@ -73,8 +52,6 @@ export function StreamMediaShell({
own, own,
naturalW, naturalW,
naturalH, naturalH,
overlayPresent: !!overlay,
senderId,
computedStyle: { ...computedStyle }, computedStyle: { ...computedStyle },
}); });
@ -82,25 +59,6 @@ export function StreamMediaShell({
return ( return (
<div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}> <div ref={bubbleRef} className={StreamMediaBubble({ own })} style={computedStyle}>
{overlay && (
<div className={StreamMediaUsernameOverlay}>
<button
type="button"
className={StreamMediaUsernameLabel}
data-user-id={senderId}
onClick={(e) => {
e.stopPropagation();
onUsernameClick?.(e);
}}
onContextMenu={(e) => {
e.stopPropagation();
onUsernameContextMenu?.(e);
}}
>
{overlay}
</button>
</div>
)}
{children} {children}
</div> </div>
); );

View file

@ -1,4 +1,4 @@
import React, { MouseEventHandler, ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { import {
IVideoContent, IVideoContent,
MATRIX_SPOILER_PROPERTY_NAME, MATRIX_SPOILER_PROPERTY_NAME,
@ -11,10 +11,6 @@ import { StreamMediaShell } from './StreamMediaShell';
export type StreamMediaVideoProps = { export type StreamMediaVideoProps = {
content: IVideoContent; content: IVideoContent;
own: boolean; own: boolean;
overlay?: ReactNode;
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
senderId?: string;
renderAsFile: () => ReactNode; renderAsFile: () => ReactNode;
renderVideoContent: (props: RenderVideoContentProps) => ReactNode; renderVideoContent: (props: RenderVideoContentProps) => ReactNode;
}; };
@ -22,10 +18,6 @@ export type StreamMediaVideoProps = {
export function StreamMediaVideo({ export function StreamMediaVideo({
content, content,
own, own,
overlay,
onUsernameClick,
onUsernameContextMenu,
senderId,
renderAsFile, renderAsFile,
renderVideoContent, renderVideoContent,
}: StreamMediaVideoProps) { }: StreamMediaVideoProps) {
@ -42,15 +34,7 @@ export function StreamMediaVideo({
} }
return ( return (
<StreamMediaShell <StreamMediaShell naturalW={videoInfo.w} naturalH={videoInfo.h} own={own}>
naturalW={videoInfo.w}
naturalH={videoInfo.h}
own={own}
overlay={overlay}
senderId={senderId}
onUsernameClick={onUsernameClick}
onUsernameContextMenu={onUsernameContextMenu}
>
{renderVideoContent({ {renderVideoContent({
body: content.body || 'Video', body: content.body || 'Video',
info: videoInfo, info: videoInfo,

View file

@ -171,15 +171,16 @@ globalStyle(`${ChannelRow}[data-bubble="true"][data-own="true"] ${ChannelMessage
globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, { globalStyle(`${ChannelRow}[data-bubble="true"][data-own="false"] ${ChannelMessageBody}`, {
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
// Peer (not-own) bubble bg — matches Stream layout's `peerBg` // Peer (not-own) bubble bg for the channel layout — its own var. (The 1-1
// variant. Covers channels main timeline AND thread drawer // 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). // (both pass `headerInBubble`, so `data-bubble="true"` fires).
backgroundColor: 'var(--vojo-peer-bubble-bg)', backgroundColor: 'var(--vojo-peer-bubble-bg)',
}); });
// Small gap so the in-bubble header (username + time) doesn't sit flush // Small gap so the in-bubble header (username + time) doesn't sit flush
// against the first line of message text. Matches `StreamBubbleHeader`'s // against the first line of message text. Matches the Stream layout's
// 2px gap. // `StreamName` 2px marginBottom.
globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, { globalStyle(`${ChannelRow}[data-bubble="true"] ${ChannelHeader}[data-in-bubble="true"]`, {
marginBottom: toRem(2), marginBottom: toRem(2),
}); });

View file

@ -1,6 +1,6 @@
import React, { ReactNode, useImperativeHandle, useRef } from 'react'; import React, { ReactNode, useImperativeHandle, useRef } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { as } from 'folds'; import { as, toRem } from 'folds';
import * as css from './layout.css'; import * as css from './layout.css';
import { useStreamLayoutDebug } from './streamDebug'; import { useStreamLayoutDebug } from './streamDebug';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; 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. // chat. The auto-sized grid column then matches the surrounding message rows.
const DAY_DIVIDER_PLACEHOLDER_TS = 0; 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): // Visual structure (3-track CSS grid, see StreamRoot in layout.css.ts):
// ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐ // ┌─G─┬─time─┬─G─┬─dot─┬─G─┬───── bubble (1fr) ─────────┐
@ -34,15 +45,30 @@ export type StreamLayoutProps = {
time?: ReactNode; time?: ReactNode;
dotColor: string; dotColor: string;
dotOpacity: number; 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; 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; 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; header?: ReactNode;
railStart?: boolean; railStart?: boolean;
railEnd?: 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 // Image messages: bubble bg/border/padding collapse so the
// StreamMediaImage child supplies the visible chrome. // StreamMediaImage child supplies the visible chrome.
mediaMode?: boolean; mediaMode?: boolean;
@ -106,12 +132,14 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
time, time,
dotColor, dotColor,
dotOpacity, dotOpacity,
dotProminent,
isOwn, isOwn,
peerBg,
compact, compact,
collapsed,
header, header,
railStart, railStart,
railEnd, railEnd,
railHidden,
mediaMode, mediaMode,
reactions, reactions,
threadSummary, threadSummary,
@ -145,45 +173,70 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
return ( return (
<div <div
className={classNames(css.StreamRoot({ compact: !!compact }), className)} className={classNames(
css.StreamRoot({ compact: !!compact, collapsed: !!collapsed }),
className
)}
{...props} {...props}
ref={rootRef} ref={rootRef}
> >
<span className={css.StreamHeaderTime} ref={timeRef}> {/* 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. */}
<span
className={classNames(css.StreamHeaderTime, collapsed && css.StreamHeaderTimeHidden)}
aria-hidden={collapsed || undefined}
ref={timeRef}
>
{time} {time}
</span> </span>
<span className={css.StreamDotColumn} aria-hidden> <span className={css.StreamDotColumn} aria-hidden>
<span {/* The rail is suppressed entirely on trailing continuation rows
className={classNames( (after the last dot) so the line stops at the last dot instead of
css.StreamRail, bleeding down through the dot-less tail of a run. */}
railStart && railEnd && css.StreamRailSingle, {!railHidden && (
railStart && !railEnd && css.StreamRailStart,
railEnd && !railStart && css.StreamRailEnd
)}
ref={railRef}
/>
<span className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)} ref={dotRef}>
<span <span
className={css.StreamDotFill} className={classNames(
style={{ backgroundColor: dotColor, opacity: dotOpacity }} css.StreamRail,
railStart && railEnd && css.StreamRailSingle,
railStart && !railEnd && css.StreamRailStart,
railEnd && !railStart && css.StreamRailEnd
)}
ref={railRef}
/> />
</span> )}
{/* No dot on collapsed continuation rows the rail passes straight
through, anchored by the first message's dot above. */}
{!collapsed && (
<span
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
style={{
width: dotProminent ? STREAM_DOT_PROMINENT : STREAM_DOT_NEUTRAL,
height: dotProminent ? STREAM_DOT_PROMINENT : STREAM_DOT_NEUTRAL,
}}
ref={dotRef}
>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
)}
</span> </span>
<div className={css.StreamColumn}> <div className={css.StreamColumn}>
{header && (
<div className={css.StreamName} ref={headerRef}>
{header}
</div>
)}
<div <div
className={css.StreamBubble({ className={css.StreamBubble({
own: !!isOwn, own: !!isOwn,
compact: !!compact, compact: !!compact,
peerBg: !!peerBg,
mediaMode: !!mediaMode, mediaMode: !!mediaMode,
})} })}
ref={bubbleRef} ref={bubbleRef}
> >
{header && (
<div className={css.StreamBubbleHeader} ref={headerRef}>
{header}
</div>
)}
{children} {children}
</div> </div>
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>} {threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}

View file

@ -138,7 +138,8 @@ export const UsernameBold = style({
fontWeight: 550, 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: // 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 // loosen on mobile without disturbing the screen-edge anchor (which the
// user dialled in earlier and asked to keep). // user dialled in earlier and asked to keep).
// //
// Mobile: pad = S100 (minimal screen-edge anchor — already at the limit, // The whole time→dot→nick block was nudged ~1mm (~4px) to the right per the
// dropping further would push timestamp glyphs flush against the screen // latest request — both pad values stepped up one token.
// edge); gap = 2 × S100 = S200 (the user asked to double the inter-element // Mobile: pad = S200 (8px — was S100; +4px ≈ 1mm off the screen edge); gap =
// gap on native). // 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 // Desktop: pad = S500 (20px — was S400; +4px ≈ 1mm further from the PageNav,
// PageNav per user request, the column still clears the nav rail); gap = // the column still clears the nav rail); gap = S500 / 1.1 ≈ 18.2 px (the user
// S500 / 1.1 ≈ 18.2 px (the user asked to shrink the desktop inter-element // asked to shrink the desktop inter-element gap by 1.1× — keeps the layout
// gap by 1.1× — keeps the layout tighter without dropping a whole token). // tighter without dropping a whole token).
const StreamRowPadVar = createVar(); const StreamRowPadVar = createVar();
const StreamRowGapVar = createVar(); const StreamRowGapVar = createVar();
const StreamRowPadMobile = config.space.S100; const StreamRowPadMobile = config.space.S200;
const StreamRowPadDesktop = config.space.S400; const StreamRowPadDesktop = config.space.S500;
const StreamRowGapMobile = config.space.S200; const StreamRowGapMobile = config.space.S200;
const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`; const StreamRowGapDesktop = `calc(${config.space.S500} / 1.1)`;
@ -193,13 +194,22 @@ const StreamBubbleBorderWidth = '1px';
const StreamTimeLineHeight = toRem(13); const StreamTimeLineHeight = toRem(13);
const StreamRailBridgeY = config.space.S400; const StreamRailBridgeY = config.space.S400;
// Vertical centre of the bubble's header text from the row's content-area // Author name (header line) — DM «VS Code chat» redesign
// top. = bubble.borderTop (1) + bubble.paddingTop (S200) + line.T200 / 2. // (docs/plans/dm_stream_vscode_redesign.md). Bold, pure white/black, a step
// Used to push the timestamp and the dot down in their grid cells so // larger than chat body (T400 = 15px). The dot + timestamp vertically centre
// they read on the same baseline as the Username component inside the // on this line, so its line-height drives the rail geometry below.
// bubble. Rail-end's height also adds `S100` back to account for the const StreamNameFontSize = toRem(16);
// rail's negative `top` offset (it starts above the row outer edge). const StreamNameLineHeight = toRem(20);
const StreamHeaderInnerCenterY = `calc(${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
// 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({ export const StreamRoot = recipe({
base: { base: {
@ -242,8 +252,18 @@ export const StreamRoot = recipe({
paddingRight: 0, 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 // 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({ export const StreamBubble = recipe({
base: { 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, backgroundColor: color.Surface.Container,
color: color.SurfaceVariant.OnContainer, color: color.SurfaceVariant.OnContainer,
border: `${StreamBubbleBorderWidth} solid ${color.Surface.ContainerLine}`, border: `${StreamBubbleBorderWidth} solid ${color.Surface.ContainerLine}`,
paddingTop: config.space.S200, borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`,
paddingBottom: config.space.S200, // 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, minWidth: 0,
maxWidth: toRem(720), maxWidth: toRem(720),
position: 'relative', position: 'relative',
zIndex: 1, zIndex: 1,
}, },
variants: { variants: {
// Asymmetric notch — own: bottom-left flat, three corners R500+. // Own messages render as plain text on the chat background — no fill,
// Incoming: top-left flat, three corners R500+. Mirrored on the // no border, no rounding, flush-left beneath the author name and
// vertical axis so own/peer read as opposing silhouettes. // spanning the message column like a paragraph (user points 2 + 4).
own: { own: {
true: { true: {
borderRadius: `${toRem(16)} ${toRem(16)} ${toRem(16)} ${toRem(4)}`, backgroundColor: 'transparent',
}, border: 'none',
false: { borderRadius: 0,
borderRadius: `${toRem(4)} ${toRem(16)} ${toRem(16)} ${toRem(16)}`, 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 // Placeholder so the compound variants below can target the breakpoint.
// 100% of the message column). Per user feedback бабл должен быть «по compact: { true: {}, false: {} },
// размеру текстового сообщения», not stretched to the column's right // Image / video: bubble becomes a transparent shell so the
// edge on mobile. Padding still tightens on mobile (S300 vs 15px) to // StreamMediaImage child supplies the visible chrome. `display: block,
// keep the bubble visually compact on narrow viewports. // width: 100%` (NOT fit-content) so the child's `max-width: 100%` has a
compact: { // definite width to clamp against — fit-content would make the chain
true: { // 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', display: 'inline-block',
width: 'fit-content', width: 'fit-content',
maxWidth: '100%', maxWidth: '100%',
paddingLeft: config.space.S300, // Tighter horizontal padding on narrow viewports (12 × 1.1 ≈ 13.2px).
paddingRight: config.space.S300, paddingLeft: toRem(13.2),
}, paddingRight: toRem(13.2),
false: {
display: 'inline-block',
width: 'fit-content',
maxWidth: '100%',
paddingLeft: toRem(15),
paddingRight: toRem(15),
}, },
}, },
// Peer (not-own) bubble bg — differentiation between «я» and // Media shell wins over both the peer fill and the width rules above
// «не я» across every room class. Media rows neutralize this via // (incl. own's lifted max-width) — keeps image/video bubbles capped at
// the `peerBg + mediaMode` compound below (order-independent). // the same 720px as before regardless of own/peer.
peerBg: { {
true: { variants: { mediaMode: true },
backgroundColor: 'var(--vojo-peer-bubble-bg)', style: {
},
},
// 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: {
backgroundColor: 'transparent', backgroundColor: 'transparent',
border: 'none', border: 'none',
borderRadius: 0, borderRadius: 0,
padding: 0, padding: 0,
display: 'block', display: 'block',
width: '100%', 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: { defaultVariants: {
own: false, own: false,
compact: false, compact: false,
peerBg: false,
mediaMode: 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', position: 'relative',
marginBottom: toRem(2), // Symmetric top-left corner (user request): the vertical gap from the nick
fontSize: toRem(11), // down to the bubble equals the horizontal gap from the bubble's left edge
lineHeight: config.lineHeight.T200, // out to the timerail = column-gap + dot radius. Inherits StreamRowGapVar
minHeight: config.lineHeight.T200, // from StreamRoot, so it tracks the per-breakpoint gap. Only applies on run
fontWeight: 600, // 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', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: toRem(6), gap: toRem(6),
flexWrap: 'nowrap', 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 `<Text truncate>` 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 // Message-row timestamp — grid item in track 1, content-sized. Pushed
@ -545,6 +593,13 @@ globalStyle(`${StreamHeaderTime} time`, {
lineHeight: StreamTimeLineHeight, 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. // Sysline — thin single-line state-event row inside Stream layout.
// Composes with StreamRoot so the time / dot / content tracks line up // Composes with StreamRoot so the time / dot / content tracks line up
// vertically with message rows above and below. Override align-items to // vertically with message rows above and below. Override align-items to
@ -556,12 +611,15 @@ export const StreamSysline = style({
paddingBottom: toRem(2), 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({ export const StreamSyslineBody = style({
fontSize: toRem(11.5), fontSize: toRem(12),
color: color.Surface.OnContainer, color: color.Surface.OnContainer,
opacity: 0.55, opacity: 0.5,
fontFamily: fontStyle: 'italic',
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',

View file

@ -1499,7 +1499,8 @@ export function RoomTimeline({
}, [timeline]); }, [timeline]);
const renderMatrixEvent = useMatrixEventRenderer< 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 // Suppress DM-call service events from the timeline. In encrypted
@ -1516,7 +1517,8 @@ export function RoomTimeline({
timelineSet, timelineSet,
collapse, collapse,
streamRailStart, streamRailStart,
streamRailEnd streamRailEnd,
railHidden
) => { ) => {
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
@ -1541,6 +1543,7 @@ export function RoomTimeline({
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
collapse={collapse} collapse={collapse}
railHidden={railHidden}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
@ -1622,7 +1625,8 @@ export function RoomTimeline({
timelineSet, timelineSet,
collapse, collapse,
streamRailStart, streamRailStart,
streamRailEnd streamRailEnd,
railHidden
) => { ) => {
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
@ -1656,6 +1660,7 @@ export function RoomTimeline({
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
collapse={collapse} collapse={collapse}
railHidden={railHidden}
highlight={highlighted} highlight={highlighted}
edit={editId === mEventId} edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
@ -1780,7 +1785,8 @@ export function RoomTimeline({
timelineSet, timelineSet,
collapse, collapse,
streamRailStart, streamRailStart,
streamRailEnd streamRailEnd,
railHidden
) => { ) => {
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey(); const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
@ -1796,6 +1802,7 @@ export function RoomTimeline({
room={room} room={room}
mEvent={mEvent} mEvent={mEvent}
collapse={collapse} collapse={collapse}
railHidden={railHidden}
highlight={highlighted} highlight={highlighted}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction} canSendReaction={canSendReaction}
@ -2125,7 +2132,10 @@ export function RoomTimeline({
); );
let prevEvent: MatrixEvent | undefined; 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 newDivider = false;
let dayDivider = false; let dayDivider = false;
@ -2206,19 +2216,32 @@ export function RoomTimeline({
return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex)); return getTimelineEvent(itemTimeline, getTimelineRelativeIndex(item, itemBaseIndex));
}; };
// Single forward + reverse pass that records, for each visible item, whether // Whether `cur` continues `prev` as the same Stream «run» (so `cur` is a
// there is any RENDERABLE event before / after it. Used to compute Stream // dot-less continuation, not a rail head). Mirrors the render loop's
// rail-start (no renderable before) and rail-end (no renderable after). // `sameSenderRun` for the STREAM layout (no minute split): same sender +
// Crucially this looks at renderability, not at `isPrevRendered` — the // type, same day, and not across the unread boundary (unless it's our own
// latter is mutated by reaction / edit / hidden service events and would // message). Used only to place the rail endpoints — the channel layout has
// otherwise reset rail-start in the middle of a continuous DM thread. // 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 { const {
before: streamRenderableItemHasBefore, before: streamRenderableItemHasBefore,
after: streamRenderableItemHasAfter,
hasRenderable: hasRenderableEvent, hasRenderable: hasRenderableEvent,
lastHead: streamLastHeadItem,
} = (() => { } = (() => {
const before = new Map<number, boolean>(); const before = new Map<number, boolean>();
const after = new Map<number, boolean>();
const items = getItems(); const items = getItems();
const renderableFlags = items.map((item) => { const renderableFlags = items.map((item) => {
@ -2226,19 +2249,26 @@ export function RoomTimeline({
return !!ev && isRenderableTimelineEvent(ev); 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 seenBefore = false;
let prevRenderable: MatrixEvent | undefined;
let lastHead: number | undefined;
for (let index = 0; index < items.length; index += 1) { for (let index = 0; index < items.length; index += 1) {
before.set(items[index], seenBefore); 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; return { before, hasRenderable: renderableFlags.some(Boolean), lastHead };
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) };
})(); })();
const eventRenderer = (item: number) => { const eventRenderer = (item: number) => {
@ -2265,27 +2295,50 @@ export function RoomTimeline({
dayDivider = prevEvent ? !inSameDay(prevEvent.getTs(), mEvent.getTs()) : false; 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 = const collapsed =
isPrevRendered && sameSenderRun &&
!dayDivider && (messageLayout !== 'channel' ||
(!newDivider || eventSender === mx.getUserId()) && (prevRenderedEvent !== undefined &&
prevEvent !== undefined && minuteDifference(prevRenderedEvent.getTs(), mEvent.getTs()) < 2));
prevEvent.getSender() === eventSender &&
prevEvent.getType() === mEvent.getType() &&
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
// streamRailStart looks at the precomputed «is there a renderable event // streamRailStart looks at the precomputed «is there a renderable event
// before me in the visible window» — not at `isPrevRendered`, which is // before me in the visible window» — not at the immediately preceding
// false for the row right after a reaction / edit / hidden service event // item, which is non-rendered right after a reaction / edit / hidden
// and would otherwise restart the rail mid-conversation. Symmetric with // 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 // 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 // visible window is sitting at the genuine timeline start AND no further
// back-pagination is possible — otherwise the «origin» dot would be a // back-pagination is possible — otherwise the «origin» dot would be a
// lie about an earlier untouched history. // lie about an earlier untouched history.
const streamRailStart = const streamRailStart =
rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false; 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 = 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 // Channels-mode renderer gate — same helper as the predicate above so
// the rail-endpoint scan and the renderer never disagree on visibility. // the rail-endpoint scan and the renderer never disagree on visibility.
@ -2303,10 +2356,11 @@ export function RoomTimeline({
timelineSet, timelineSet,
collapsed, collapsed,
streamRailStart, streamRailStart,
streamRailEnd streamRailEnd,
streamRailHidden
); );
prevEvent = mEvent; prevEvent = mEvent;
isPrevRendered = !!eventJSX; if (eventJSX) prevRenderedEvent = mEvent;
if (newDivider && eventJSX) { if (newDivider && eventJSX) {
// TODO(P3c-followup): replace the legacy full-width unread divider with // TODO(P3c-followup): replace the legacy full-width unread divider with

View file

@ -20,6 +20,16 @@ import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// same Android-WebView stuck-:hover suppression. // same Android-WebView stuck-:hover suppression.
export const ChatComposer = style({}); 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 // Outer absolute-positioned wrapper for the composer overlay. Carries the
// slide/fade transition driven by the `data-hidden` attribute set from // slide/fade transition driven by the `data-hidden` attribute set from
// React state. CSS class (not inline `transition`) so the // React state. CSS class (not inline `transition`) so the

View file

@ -3,6 +3,8 @@ import { Box, Text, color, config, toRem } from 'folds';
import { EventType } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react'; import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@ -89,6 +91,10 @@ export function RoomView({ eventId }: { eventId?: string }) {
// `TimelineRenderingType.Thread` context. // `TimelineRenderingType.Thread` context.
const threadDrawerOpen = useThreadDrawerOpen(); 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(() => { useEffect(() => {
const el = composerWrapRef.current; const el = composerWrapRef.current;
if (!el) { if (!el) {
@ -171,7 +177,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
onFocusCapture={() => setComposerHidden(false)} onFocusCapture={() => setComposerHidden(false)}
> >
<div <div
className={css.ChatComposer} className={classNames(css.ChatComposer, isDesktop && css.ComposerDesktopClamp)}
style={{ style={{
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`, padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
}} }}

View file

@ -109,7 +109,6 @@ export function CallMessage({
const senderId = aggregate.anchorSenderId; const senderId = aggregate.anchorSenderId;
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId(); const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const peerBg = !isOwnMessage;
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId; const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
@ -118,7 +117,9 @@ export function CallMessage({
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
const usernameStyle = { color: usernameColor ?? color.Primary.Main }; const usernameStyle = { color: usernameColor ?? color.Primary.Main };
const dot = useDotColor(room, mEvent, true, hideReadReceipts); // Only the Stream layout renders the rail dot — skip the discarded
// push-action eval / Receipt subscription on channel-layout rows.
const dot = useDotColor(room, mEvent, layout !== 'channel', hideReadReceipts);
// Other side never joined → caller's view «Cancelled», callee's view «Missed». // Other side never joined → caller's view «Cancelled», callee's view «Missed».
// When `mergedCount > 1` the bubble represents a chain of retries that // When `mergedCount > 1` the bubble represents a chain of retries that
@ -172,13 +173,10 @@ export function CallMessage({
</Box> </Box>
); );
const streamHeader = ( // No inline colour/size — the `StreamName` wrapper supplies the bold,
<Username as="span" style={usernameStyle}> // pure white/black, larger-than-body styling that the name inherits. Own
<Text as="span" size="T200" truncate> // events show the user's own nick (display name), not a «me» label.
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold> const streamHeader = <Username as="span">{senderName}</Username>;
</Text>
</Username>
);
return ( return (
<Event <Event
@ -218,8 +216,8 @@ export function CallMessage({
time={<Time ts={mEvent.getTs()} compact />} time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color} dotColor={dot.color}
dotOpacity={dot.opacity} dotOpacity={dot.opacity}
dotProminent={dot.prominent}
isOwn={isOwnMessage} isOwn={isOwnMessage}
peerBg={peerBg}
compact={isMobile} compact={isMobile}
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}

View file

@ -656,6 +656,11 @@ export type MessageProps = {
room: Room; room: Room;
mEvent: MatrixEvent; mEvent: MatrixEvent;
collapse: boolean; 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; highlight: boolean;
edit?: boolean; edit?: boolean;
canDelete?: boolean; canDelete?: boolean;
@ -727,6 +732,7 @@ const MessageInner = as<'div', MessageProps>(
room, room,
mEvent, mEvent,
collapse, collapse,
railHidden,
highlight, highlight,
edit, edit,
canDelete, canDelete,
@ -779,10 +785,11 @@ const MessageInner = as<'div', MessageProps>(
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor; 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 screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile; const isMobile = screenSize === ScreenSize.Mobile;
const peerBg = !isOwnMessage;
// msgType comes from the parent — RoomTimeline reads // msgType comes from the parent — RoomTimeline reads
// `mEvent.getContent().msgtype` synchronously and re-evaluates inside // `mEvent.getContent().msgtype` synchronously and re-evaluates inside
@ -806,28 +813,11 @@ const MessageInner = as<'div', MessageProps>(
const streamMediaCtx = useMemo( const streamMediaCtx = useMemo(
() => () =>
// Stream-only: the overlay username on top of media collapses the // Media chrome only needs `own` (for the bubble's notch corner). The
// bubble's header slot. Channel layout renders username above the // sender nick is rendered ABOVE the media by the Stream name header
// image normally, so the overlay is suppressed (`null` ctx). // now (like text) — there's no overlay on the image any more.
mediaMode && layout === 'stream' mediaMode && layout === 'stream' ? { own: isOwnMessage } : null,
? { [mediaMode, layout, isOwnMessage]
own: isOwnMessage,
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
senderId,
onUsernameClick,
onUsernameContextMenu: onUserClick,
}
: null,
[
mediaMode,
layout,
isOwnMessage,
senderDisplayName,
senderId,
onUsernameClick,
onUserClick,
t,
]
); );
const msgContentJSX = ( const msgContentJSX = (
@ -1215,28 +1205,38 @@ const MessageInner = as<'div', MessageProps>(
time={<Time ts={mEvent.getTs()} compact />} time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color} dotColor={dot.color}
dotOpacity={dot.opacity} dotOpacity={dot.opacity}
dotProminent={dot.prominent}
isOwn={isOwnMessage} isOwn={isOwnMessage}
peerBg={peerBg}
compact={isMobile} compact={isMobile}
// A same-sender continuation row (any minute): hide the dot / time
// / nick and stack the body tight under the previous one. Only the
// first message of a run keeps the full header. See RoomTimeline
// `collapsed`.
collapsed={collapse}
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
railHidden={railHidden}
mediaMode={mediaMode} mediaMode={mediaMode}
reactions={reactions} reactions={reactions}
threadSummary={threadSummary} threadSummary={threadSummary}
header={ header={
mediaMode ? undefined : ( // Author nick prints once at the head of a same-sender run; every
// continuation row (`collapse`) drops it. Media heads show the
// nick here too (above the media) — there's no overlay on the
// image any more.
collapse ? undefined : (
// No inline colour / size — the `StreamName` wrapper supplies
// the bold, pure white/black, larger-than-body styling, and the
// button inherits it. css.Username already truncates.
<Username <Username
as="button" as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId} data-user-id={senderId}
onContextMenu={onUserClick} onContextMenu={onUserClick}
onClick={onUsernameClick} onClick={onUsernameClick}
> >
<Text as="span" size="T200" truncate> {/* Own messages show the user's own nick (display name), not
<UsernameBold> a «me» label VS-Code-style per-author turn labels. */}
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName} {senderDisplayName}
</UsernameBold>
</Text>
</Username> </Username>
) )
} }
@ -1314,6 +1314,7 @@ function areMessagePropsEqual(
prev.mEvent === next.mEvent && prev.mEvent === next.mEvent &&
prev.relations === next.relations && prev.relations === next.relations &&
prev.collapse === next.collapse && prev.collapse === next.collapse &&
prev.railHidden === next.railHidden &&
prev.highlight === next.highlight && prev.highlight === next.highlight &&
prev.edit === next.edit && prev.edit === next.edit &&
prev.canDelete === next.canDelete && prev.canDelete === next.canDelete &&

View file

@ -1,19 +1,17 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { Box, Text } from 'folds'; import { Box, Icons, Text } from 'folds';
import { MatrixEvent, Room } from 'matrix-js-sdk'; import { MatrixEvent, Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
ChannelLayout, ChannelLayout,
ChannelMessageAvatar, ChannelMessageAvatar,
StreamLayout, EventContent,
Time, Time,
Username, Username,
UsernameBold, UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { Event } from './Message'; import { Event } from './Message';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useDotColor } from '../../../hooks/useDotColor';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { getMemberDisplayName } from '../../../utils/room'; import { getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix'; import { getMxIdLocalPart } from '../../../utils/matrix';
@ -21,8 +19,8 @@ export type SyslineMessageProps = {
room: Room; room: Room;
mEvent: MatrixEvent; mEvent: MatrixEvent;
// Pre-rendered body — already includes any sender names / interpolation // Pre-rendered body — already includes any sender names / interpolation
// the per-type renderer wants to show. The sysline bubble adds no extra // the per-type renderer wants to show. The sysline row adds no extra
// header or icon, so the body is the only visible content. // header, so the body is the only visible content.
body: ReactNode; body: ReactNode;
highlight: boolean; highlight: boolean;
canDelete?: boolean; canDelete?: boolean;
@ -53,22 +51,10 @@ export function SyslineMessage({
}: SyslineMessageProps) { }: SyslineMessageProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId(); const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
const peerBg = !isOwnMessage; const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const senderName =
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
const bubbleBody = (
<Box style={{ minWidth: 0 }}>
<Text as="span" size="T300" priority="300">
{body}
</Text>
</Box>
);
return ( return (
<Event <Event
@ -86,11 +72,7 @@ export function SyslineMessage({
isOwn={isOwnMessage} isOwn={isOwnMessage}
headerInBubble={channelHeaderInBubble} headerInBubble={channelHeaderInBubble}
avatar={ avatar={
<ChannelMessageAvatar <ChannelMessageAvatar room={room} senderId={senderId} senderDisplayName={senderName} />
room={room}
senderId={senderId}
senderDisplayName={senderName}
/>
} }
header={ header={
<> <>
@ -105,21 +87,26 @@ export function SyslineMessage({
</> </>
} }
> >
{bubbleBody} <Box style={{ minWidth: 0 }}>
<Text as="span" size="T300" priority="300">
{body}
</Text>
</Box>
</ChannelLayout> </ChannelLayout>
) : ( ) : (
<StreamLayout // System notices (room name / topic / avatar changes) render as
// muted «Thinking»-style rail rows — the same understated treatment
// as every other Stream sysline (user point 10), never as a chat
// bubble. The rail dot is a fixed neutral grey (EventContent owns it),
// since a system event has no message read-state to encode.
<EventContent
time={<Time ts={mEvent.getTs()} compact />} time={<Time ts={mEvent.getTs()} compact />}
dotColor={dot.color} iconSrc={Icons.Info}
dotOpacity={dot.opacity} content={body}
isOwn={isOwnMessage}
peerBg={peerBg}
compact={isMobile}
railStart={streamRailStart} railStart={streamRailStart}
railEnd={streamRailEnd} railEnd={streamRailEnd}
> layout="stream"
{bubbleBody} />
</StreamLayout>
)} )}
</Event> </Event>
); );

View file

@ -1,17 +1,31 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { color } from 'folds'; import { color } from 'folds';
import { cssColorMXID } from '../../util/colorMXID';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus'; import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus';
export type DotColor = { color: string; opacity: number }; // `prominent` is true for the «state» dots (green = read, gold = mention, red
// = failed) and false for the neutral gray. The Stream layout draws prominent
// dots 1.1× the size of the neutral ones so they catch the eye on the rail.
export type DotColor = { color: string; opacity: number; prominent: boolean };
// Variant A + C decision tree (docs/plans/dm_1x1_redesign.md §6.5b): // Gray rail dot — neutral state for «unread» (my undelivered / any incoming
// * mention-highlight (push-actions tweaks.highlight) → Warning gold, override // not-yet-read). Theme-aware CSS var (light/dark in src/index.css).
// * own NOT_SENT / CANCELLED → Critical, full const DOT_NEUTRAL = 'var(--vojo-dot-neutral)';
// * own otherwise → Primary, dim → full when peer reads
// * incoming → cssColorMXID hash, full → dim once we read // DM «VS Code chat» dot decision tree
// (docs/plans/dm_stream_vscode_redesign.md §5):
// * mention-highlight (push-actions tweaks.highlight) → gold (Warning), full — override
// * own NOT_SENT / CANCELLED → red (Critical), full
// * own sending → gray, dim (in-flight)
// * own read by peer → green (Success), full
// * own sent-not-read → gray, full
// * incoming I have read → gray, dim
// * incoming unread → gray, full
//
// Unlike the previous Stream palette, the dot no longer carries the author's
// mxid-hash colour — every state is gray/green/gold/red so the rail reads as a
// delivery/read ledger (like the VS Code reference), not an author legend.
// //
// Mention detection goes through the canonical `mx.getPushActionsForEvent` // Mention detection goes through the canonical `mx.getPushActionsForEvent`
// path (m.mentions, display-name match, configured keywords, room-mentions via // path (m.mentions, display-name match, configured keywords, room-mentions via
@ -28,8 +42,8 @@ export type DotColor = { color: string; opacity: number };
// `hideReadReceipts` mirrors the legacy MessageStatus checkmark suppression: // `hideReadReceipts` mirrors the legacy MessageStatus checkmark suppression:
// when the user opted into «Hide Typing & Read Receipts» we MUST NOT reveal // 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 // 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 // asymmetric. With the flag on, own messages never flip to green — Sent and
// «sending» state — Sent and Read both render at full opacity. // Read both render as a full-opacity gray dot.
export function useDotColor( export function useDotColor(
room: Room, room: Room,
mEvent: MatrixEvent, mEvent: MatrixEvent,
@ -63,45 +77,36 @@ export function useDotColor(
}, [room, myUserId, eventId, isOwn, enabled]); }, [room, myUserId, eventId, isOwn, enabled]);
if (!enabled) { if (!enabled) {
return { color: color.Primary.Main, opacity: 1.0 }; return { color: color.Primary.Main, opacity: 1.0, prominent: false };
} }
const pushActions = mx.getPushActionsForEvent(mEvent.replacingEvent() ?? mEvent); const pushActions = mx.getPushActionsForEvent(mEvent.replacingEvent() ?? mEvent);
if (pushActions?.tweaks?.highlight) { if (pushActions?.tweaks?.highlight) {
return { color: color.Warning.Main, opacity: 1.0 }; return { color: color.Warning.Main, opacity: 1.0, prominent: true };
} }
if (isOwn) { if (isOwn) {
if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) { if (mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED) {
return { color: color.Critical.Main, opacity: 1.0 }; return { color: color.Critical.Main, opacity: 1.0, prominent: true };
} }
// Sending state always dims, regardless of hideReadReceipts — the in-flight // Sending state always dims, regardless of hideReadReceipts — the in-flight
// signal is local to the sender and reveals nothing about the peer. // signal is local to the sender and reveals nothing about the peer.
if (status === MessageDeliveryStatus.Sending) { if (status === MessageDeliveryStatus.Sending) {
return { color: color.Primary.Main, opacity: 0.3 }; return { color: DOT_NEUTRAL, opacity: 0.45, prominent: false };
} }
if (hideReadReceipts) { if (hideReadReceipts) {
// Privacy mode: don't differentiate Sent vs Read — both render full, // Privacy mode: don't reveal Sent-vs-Read via colour — both render as a
// matching the single-checkmark behaviour of MessageStatus.tsx when // full-opacity neutral dot, matching the single-checkmark behaviour of
// the same flag is on. // MessageStatus.tsx when the same flag is on.
return { color: color.Primary.Main, opacity: 1.0 }; return { color: DOT_NEUTRAL, opacity: 1.0, prominent: false };
} }
return { // Read by the peer → green (prominent); still only delivered → neutral gray.
color: color.Primary.Main, return status === MessageDeliveryStatus.Read
// Stronger opacity contrast than the original 0.4: at 0.3 the dot still ? { color: color.Success.Main, opacity: 1.0, prominent: true }
// reads as Fleet-violet (own author colour) but is clearly less alive : { color: DOT_NEUTRAL, opacity: 1.0, prominent: false };
// 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 { // Incoming: neutral gray, dimmed once we've read it (so a fresh unread
color: `var(${cssColorMXID(senderId)})`, // message still pops on the rail) — no author-hash colour any more.
// Same logic for incoming dots: keep the author hash colour visible at return { color: DOT_NEUTRAL, opacity: haveSeen ? 0.4 : 1.0, prominent: false };
// all times so group-DM authorship stays scannable; only modulate the
// dot's brightness via opacity to mark «I've seen it» (0.3) vs fresh (1.0).
opacity: haveSeen ? 0.3 : 1.0,
};
} }

View file

@ -17,6 +17,27 @@ export const useGetMemberPowerTag = (
const creatorsTag = useRoomCreatorsTag(); const creatorsTag = useRoomCreatorsTag();
const powerLevelTags = usePowerLevelTags(room, powerLevels); const powerLevelTags = usePowerLevelTags(room, powerLevels);
// Cache the resolved tag BY POWER LEVEL so identical inputs return the
// identical object reference across renders. This matters for two reasons:
// 1. Perf — `getPowerLevelTag` falls back to `generateFallbackTagSimple`
// (a freshly-allocated `{ name }`) for any power with no defined tag
// (the common default-power case). Without this cache, each call
// returns a new object, so `memberPowerTag` is a new reference every
// render and defeats the `Message` React.memo — every visible timeline
// row then re-renders on every scroll/receipt tick (visible jank).
// 2. Grouping — `useFlattenPowerTagMembers` groups members by `tag !==
// prevTag` reference equality; keying the cache on power (not userId)
// keeps same-power members sharing one tag object.
// Reset whenever the tag table changes; `powerLevels` only shifts which
// power a user resolves to, which simply selects a different cache entry.
// `powerLevelTags` is the cache's invalidation key (its identity), not read
// inside the factory — hence the exhaustive-deps disable.
const tagByPower = useMemo<Map<number, MemberPowerTag>>(
() => new Map(),
// eslint-disable-next-line react-hooks/exhaustive-deps
[powerLevelTags]
);
const getMemberPowerTag: GetMemberPowerTag = useCallback( const getMemberPowerTag: GetMemberPowerTag = useCallback(
(userId) => { (userId) => {
if (creators.has(userId)) { if (creators.has(userId)) {
@ -24,9 +45,14 @@ export const useGetMemberPowerTag = (
} }
const power = readPowerLevel.user(powerLevels, userId); const power = readPowerLevel.user(powerLevels, userId);
return getPowerLevelTag(powerLevelTags, power); let tag = tagByPower.get(power);
if (!tag) {
tag = getPowerLevelTag(powerLevelTags, power);
tagByPower.set(power, tag);
}
return tag;
}, },
[creators, creatorsTag, powerLevels, powerLevelTags] [creators, creatorsTag, powerLevels, powerLevelTags, tagByPower]
); );
return getMemberPowerTag; return getMemberPowerTag;

View file

@ -21,15 +21,29 @@
default is the light-theme value; `.dark-theme` overrides below. */ default is the light-theme value; `.dark-theme` overrides below. */
--vojo-horseshoe-void: #d6d6e3; --vojo-horseshoe-void: #d6d6e3;
/* Peer (not-own) bubble bg every «чужое» message reshades to /* Peer (not-own) bubble bg for the CHANNEL layout (groups / channels /
this, in 1-1 DMs, groups, channels alike. Light: slight off-white thread drawer). The 1-1 Stream layout's incoming bubble does NOT use this
step from #ffffff. Dark override below. */ it binds directly to `color.Surface.Container` (the composer surface).
Light: slight off-white step from #ffffff. Dark override below. */
--vojo-peer-bubble-bg: #f5f5fa; --vojo-peer-bubble-bg: #f5f5fa;
/* Stream timeline rail (vertical line through dots) + day-divider /* Stream timeline rail (vertical line through dots) + day-divider
horizontal segments. Light: hairline cool-grey; dark overrides horizontal segments. Light: hairline cool-grey; dark overrides
below. */ below. */
--vojo-timeline-rail: #e8e8f2; --vojo-timeline-rail: #e8e8f2;
/* DM 1-1 «VS Code chat» redesign tokens (see
docs/plans/dm_stream_vscode_redesign.md). Light-theme defaults here;
dark overrides in `.dark-theme`.
* stream-name author label colour (pure black light / pure white
dark, per spec).
* dot-neutral gray rail dot for unread / in-flight (green = my-read,
gold = mention, red = my-failed are folds tokens in
useDotColor).
NB: the incoming-bubble fill is NOT a var `StreamBubble` binds it to
`color.Surface.Container` so it always matches the composer card. */
--vojo-stream-name: #000000;
--vojo-dot-neutral: #9aa0aa;
--font-emoji: 'Twemoji_DISABLED'; --font-emoji: 'Twemoji_DISABLED';
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif; --font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
} }
@ -49,7 +63,14 @@
--vojo-horseshoe-void: #000000; --vojo-horseshoe-void: #000000;
--vojo-peer-bubble-bg: #000000; --vojo-peer-bubble-bg: #000000;
--vojo-timeline-rail: #000000; /* Subtle gray hairline so the thin rail reads against the #0d0e11
chat surface (was pure #000000, which vanished into the bg). */
--vojo-timeline-rail: #2a2e38;
/* DM 1-1 «VS Code chat» redesign dark palette. (Incoming-bubble fill
binds to color.Surface.Container in StreamBubble, not a var here.) */
--vojo-stream-name: #ffffff;
--vojo-dot-neutral: #6b7280;
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif; --font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
} }