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:
parent
153860bb38
commit
0f882567c5
17 changed files with 503 additions and 413 deletions
|
|
@ -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<HTMLButtonElement>;
|
||||
onUsernameContextMenu: MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
export const StreamMediaContext = createContext<StreamMediaContextValue | null>(null);
|
||||
export const useStreamMediaContext = (): StreamMediaContextValue | null =>
|
||||
|
|
@ -237,10 +236,6 @@ export function RenderMessageContent({
|
|||
<StreamMediaImage
|
||||
content={getContent()}
|
||||
own={streamMedia.own}
|
||||
overlay={streamMedia.username}
|
||||
senderId={streamMedia.senderId}
|
||||
onUsernameClick={streamMedia.onUsernameClick}
|
||||
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||
renderImageContent={renderImageInside}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -288,10 +283,6 @@ export function RenderMessageContent({
|
|||
<StreamMediaVideo
|
||||
content={getContent()}
|
||||
own={streamMedia.own}
|
||||
overlay={streamMedia.username}
|
||||
senderId={streamMedia.senderId}
|
||||
onUsernameClick={streamMedia.onUsernameClick}
|
||||
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||
renderAsFile={renderFile}
|
||||
renderVideoContent={renderVideoInside}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// notch lives on the image-bubble itself; stacking two notch'd rectangles
|
||||
// reads worse than one notched + one rounded.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, ReactNode } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import {
|
||||
IImageContent,
|
||||
MATRIX_SPOILER_PROPERTY_NAME,
|
||||
|
|
@ -11,42 +11,23 @@ import { StreamMediaShell } from './StreamMediaShell';
|
|||
// extensions) are useless as alt-text — bridged photos from
|
||||
// mautrix-telegram set body to the source filename. Treat those as
|
||||
// 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 : '');
|
||||
|
||||
export type StreamMediaImageProps = {
|
||||
content: IImageContent;
|
||||
own: boolean;
|
||||
overlay?: ReactNode;
|
||||
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
senderId?: string;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
};
|
||||
|
||||
export function StreamMediaImage({
|
||||
content,
|
||||
own,
|
||||
overlay,
|
||||
onUsernameClick,
|
||||
onUsernameContextMenu,
|
||||
senderId,
|
||||
renderImageContent,
|
||||
}: StreamMediaImageProps) {
|
||||
export function StreamMediaImage({ content, own, renderImageContent }: StreamMediaImageProps) {
|
||||
const imgInfo = content.info;
|
||||
const mxcUrl = content.file?.url ?? content.url;
|
||||
if (typeof mxcUrl !== 'string') return null;
|
||||
|
||||
return (
|
||||
<StreamMediaShell
|
||||
naturalW={imgInfo?.w}
|
||||
naturalH={imgInfo?.h}
|
||||
own={own}
|
||||
overlay={overlay}
|
||||
senderId={senderId}
|
||||
onUsernameClick={onUsernameClick}
|
||||
onUsernameContextMenu={onUsernameContextMenu}
|
||||
>
|
||||
<StreamMediaShell naturalW={imgInfo?.w} naturalH={imgInfo?.h} own={own}>
|
||||
{renderImageContent({
|
||||
body: altFor(content.body),
|
||||
info: imgInfo,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,6 @@
|
|||
import React, { MouseEventHandler, ReactNode, useRef } from 'react';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import { toRem } from 'folds';
|
||||
import {
|
||||
StreamMediaBubble,
|
||||
StreamMediaUsernameLabel,
|
||||
StreamMediaUsernameOverlay,
|
||||
} from './StreamMedia.css';
|
||||
import { StreamMediaBubble } from './StreamMedia.css';
|
||||
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
|
||||
|
||||
const STREAM_MEDIA_MAX_DIM = 320;
|
||||
|
|
@ -15,10 +11,6 @@ export type StreamMediaShellProps = {
|
|||
naturalW?: number;
|
||||
naturalH?: number;
|
||||
own: boolean;
|
||||
overlay?: ReactNode;
|
||||
senderId?: string;
|
||||
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
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
|
||||
// asymmetric notch + 1px pseudo-frame, and the sender username overlaid
|
||||
// top-left as a text chip. Caller plugs in the actual media renderer as
|
||||
// `children`.
|
||||
//
|
||||
// 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) {
|
||||
// asymmetric notch + 1px pseudo-frame. Caller plugs in the actual media
|
||||
// renderer as `children`. The sender nick is rendered ABOVE the media by the
|
||||
// Stream layout's name header (like text messages), not overlaid on the image.
|
||||
export function StreamMediaShell({ naturalW, naturalH, own, children }: StreamMediaShellProps) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const computedStyle = computeBoxStyle(naturalW, naturalH);
|
||||
|
||||
|
|
@ -73,8 +52,6 @@ export function StreamMediaShell({
|
|||
own,
|
||||
naturalW,
|
||||
naturalH,
|
||||
overlayPresent: !!overlay,
|
||||
senderId,
|
||||
computedStyle: { ...computedStyle },
|
||||
});
|
||||
|
||||
|
|
@ -82,25 +59,6 @@ export function StreamMediaShell({
|
|||
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<HTMLButtonElement>;
|
||||
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
|
||||
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 (
|
||||
<StreamMediaShell
|
||||
naturalW={videoInfo.w}
|
||||
naturalH={videoInfo.h}
|
||||
own={own}
|
||||
overlay={overlay}
|
||||
senderId={senderId}
|
||||
onUsernameClick={onUsernameClick}
|
||||
onUsernameContextMenu={onUsernameContextMenu}
|
||||
>
|
||||
<StreamMediaShell naturalW={videoInfo.w} naturalH={videoInfo.h} own={own}>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
className={classNames(css.StreamRoot({ compact: !!compact }), className)}
|
||||
className={classNames(
|
||||
css.StreamRoot({ compact: !!compact, collapsed: !!collapsed }),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
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}
|
||||
</span>
|
||||
<span className={css.StreamDotColumn} aria-hidden>
|
||||
<span
|
||||
className={classNames(
|
||||
css.StreamRail,
|
||||
railStart && railEnd && css.StreamRailSingle,
|
||||
railStart && !railEnd && css.StreamRailStart,
|
||||
railEnd && !railStart && css.StreamRailEnd
|
||||
)}
|
||||
ref={railRef}
|
||||
/>
|
||||
<span className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)} ref={dotRef}>
|
||||
{/* 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 && (
|
||||
<span
|
||||
className={css.StreamDotFill}
|
||||
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
||||
className={classNames(
|
||||
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>
|
||||
<div className={css.StreamColumn}>
|
||||
{header && (
|
||||
<div className={css.StreamName} ref={headerRef}>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={css.StreamBubble({
|
||||
own: !!isOwn,
|
||||
compact: !!compact,
|
||||
peerBg: !!peerBg,
|
||||
mediaMode: !!mediaMode,
|
||||
})}
|
||||
ref={bubbleRef}
|
||||
>
|
||||
{header && (
|
||||
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
{threadSummary && <div className={css.StreamThreadSummary}>{threadSummary}</div>}
|
||||
|
|
|
|||
|
|
@ -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 `<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
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<number, boolean>();
|
||||
const after = new Map<number, boolean>();
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
<div
|
||||
className={css.ChatComposer}
|
||||
className={classNames(css.ChatComposer, isDesktop && css.ComposerDesktopClamp)}
|
||||
style={{
|
||||
padding: `0 ${toRem(VOJO_HORSESHOE_GAP_PX)} ${toRem(VOJO_HORSESHOE_GAP_PX)}`,
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -109,7 +109,6 @@ export function CallMessage({
|
|||
|
||||
const senderId = aggregate.anchorSenderId;
|
||||
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
|
||||
const peerBg = !isOwnMessage;
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||
|
||||
const tagColor = memberPowerTag?.color
|
||||
|
|
@ -118,7 +117,9 @@ export function CallMessage({
|
|||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||
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».
|
||||
// When `mergedCount > 1` the bubble represents a chain of retries that
|
||||
|
|
@ -172,13 +173,10 @@ export function CallMessage({
|
|||
</Box>
|
||||
);
|
||||
|
||||
const streamHeader = (
|
||||
<Username as="span" style={usernameStyle}>
|
||||
<Text as="span" size="T200" truncate>
|
||||
<UsernameBold>{isOwnMessage ? t('Direct.message_me_label') : senderName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
);
|
||||
// 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 = <Username as="span">{senderName}</Username>;
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -218,8 +216,8 @@ export function CallMessage({
|
|||
time={<Time ts={mEvent.getTs()} compact />}
|
||||
dotColor={dot.color}
|
||||
dotOpacity={dot.opacity}
|
||||
dotProminent={dot.prominent}
|
||||
isOwn={isOwnMessage}
|
||||
peerBg={peerBg}
|
||||
compact={isMobile}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
|
|
|
|||
|
|
@ -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={<Time ts={mEvent.getTs()} compact />}
|
||||
dotColor={dot.color}
|
||||
dotOpacity={dot.opacity}
|
||||
dotProminent={dot.prominent}
|
||||
isOwn={isOwnMessage}
|
||||
peerBg={peerBg}
|
||||
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}
|
||||
railEnd={streamRailEnd}
|
||||
railHidden={railHidden}
|
||||
mediaMode={mediaMode}
|
||||
reactions={reactions}
|
||||
threadSummary={threadSummary}
|
||||
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
|
||||
as="button"
|
||||
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||
data-user-id={senderId}
|
||||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size="T200" truncate>
|
||||
<UsernameBold>
|
||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||
</UsernameBold>
|
||||
</Text>
|
||||
{/* Own messages show the user's own nick (display name), not
|
||||
a «me» label — VS-Code-style per-author turn labels. */}
|
||||
{senderDisplayName}
|
||||
</Username>
|
||||
)
|
||||
}
|
||||
|
|
@ -1314,6 +1314,7 @@ function areMessagePropsEqual(
|
|||
prev.mEvent === next.mEvent &&
|
||||
prev.relations === next.relations &&
|
||||
prev.collapse === next.collapse &&
|
||||
prev.railHidden === next.railHidden &&
|
||||
prev.highlight === next.highlight &&
|
||||
prev.edit === next.edit &&
|
||||
prev.canDelete === next.canDelete &&
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, Text } from 'folds';
|
||||
import { Box, Icons, Text } from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChannelLayout,
|
||||
ChannelMessageAvatar,
|
||||
StreamLayout,
|
||||
EventContent,
|
||||
Time,
|
||||
Username,
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import { Event } from './Message';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useDotColor } from '../../../hooks/useDotColor';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { getMemberDisplayName } from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
|
||||
|
|
@ -21,8 +19,8 @@ export type SyslineMessageProps = {
|
|||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
// Pre-rendered body — already includes any sender names / interpolation
|
||||
// the per-type renderer wants to show. The sysline bubble adds no extra
|
||||
// header or icon, so the body is the only visible content.
|
||||
// the per-type renderer wants to show. The sysline row adds no extra
|
||||
// header, so the body is the only visible content.
|
||||
body: ReactNode;
|
||||
highlight: boolean;
|
||||
canDelete?: boolean;
|
||||
|
|
@ -53,22 +51,10 @@ export function SyslineMessage({
|
|||
}: SyslineMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
|
||||
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const isOwnMessage = !!mx.getUserId() && senderId === mx.getUserId();
|
||||
const peerBg = !isOwnMessage;
|
||||
const senderName =
|
||||
getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||
|
||||
const bubbleBody = (
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text as="span" size="T300" priority="300">
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
const senderName = getMemberDisplayName(room, senderId) || getMxIdLocalPart(senderId) || senderId;
|
||||
|
||||
return (
|
||||
<Event
|
||||
|
|
@ -86,11 +72,7 @@ export function SyslineMessage({
|
|||
isOwn={isOwnMessage}
|
||||
headerInBubble={channelHeaderInBubble}
|
||||
avatar={
|
||||
<ChannelMessageAvatar
|
||||
room={room}
|
||||
senderId={senderId}
|
||||
senderDisplayName={senderName}
|
||||
/>
|
||||
<ChannelMessageAvatar room={room} senderId={senderId} senderDisplayName={senderName} />
|
||||
}
|
||||
header={
|
||||
<>
|
||||
|
|
@ -105,21 +87,26 @@ export function SyslineMessage({
|
|||
</>
|
||||
}
|
||||
>
|
||||
{bubbleBody}
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text as="span" size="T300" priority="300">
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
</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 />}
|
||||
dotColor={dot.color}
|
||||
dotOpacity={dot.opacity}
|
||||
isOwn={isOwnMessage}
|
||||
peerBg={peerBg}
|
||||
compact={isMobile}
|
||||
iconSrc={Icons.Info}
|
||||
content={body}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
>
|
||||
{bubbleBody}
|
||||
</StreamLayout>
|
||||
layout="stream"
|
||||
/>
|
||||
)}
|
||||
</Event>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,17 +1,31 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { EventStatus, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { color } from 'folds';
|
||||
import { cssColorMXID } from '../../util/colorMXID';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { MessageDeliveryStatus, useMessageStatus } from './useMessageStatus';
|
||||
|
||||
export type DotColor = { color: string; opacity: number };
|
||||
// `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):
|
||||
// * mention-highlight (push-actions tweaks.highlight) → Warning gold, override
|
||||
// * own NOT_SENT / CANCELLED → Critical, full
|
||||
// * own otherwise → Primary, dim → full when peer reads
|
||||
// * incoming → cssColorMXID hash, full → dim once we read
|
||||
// Gray rail dot — neutral state for «unread» (my undelivered / any incoming
|
||||
// not-yet-read). Theme-aware CSS var (light/dark in src/index.css).
|
||||
const DOT_NEUTRAL = 'var(--vojo-dot-neutral)';
|
||||
|
||||
// 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`
|
||||
// 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:
|
||||
// when the user opted into «Hide Typing & Read Receipts» we MUST NOT reveal
|
||||
// peer-read state via the dot either, otherwise the privacy guarantee is
|
||||
// asymmetric. With the flag on, own messages stop dimming once they leave the
|
||||
// «sending» state — Sent and Read both render at full opacity.
|
||||
// asymmetric. With the flag on, own messages never flip to green — Sent and
|
||||
// Read both render as a full-opacity gray dot.
|
||||
export function useDotColor(
|
||||
room: Room,
|
||||
mEvent: MatrixEvent,
|
||||
|
|
@ -63,45 +77,36 @@ export function useDotColor(
|
|||
}, [room, myUserId, eventId, isOwn, 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);
|
||||
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 (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
|
||||
// signal is local to the sender and reveals nothing about the peer.
|
||||
if (status === MessageDeliveryStatus.Sending) {
|
||||
return { color: color.Primary.Main, opacity: 0.3 };
|
||||
return { color: DOT_NEUTRAL, opacity: 0.45, prominent: false };
|
||||
}
|
||||
if (hideReadReceipts) {
|
||||
// Privacy mode: don't differentiate Sent vs Read — both render full,
|
||||
// matching the single-checkmark behaviour of MessageStatus.tsx when
|
||||
// the same flag is on.
|
||||
return { color: color.Primary.Main, opacity: 1.0 };
|
||||
// Privacy mode: don't reveal Sent-vs-Read via colour — both render as a
|
||||
// full-opacity neutral dot, matching the single-checkmark behaviour of
|
||||
// MessageStatus.tsx when the same flag is on.
|
||||
return { color: DOT_NEUTRAL, opacity: 1.0, prominent: false };
|
||||
}
|
||||
return {
|
||||
color: color.Primary.Main,
|
||||
// Stronger opacity contrast than the original 0.4: at 0.3 the dot still
|
||||
// reads as Fleet-violet (own author colour) but is clearly less alive
|
||||
// than the 1.0 read state. Author colour is preserved on purpose — the
|
||||
// user explicitly asked NOT to grey-out / desaturate read dots, only to
|
||||
// dim them through opacity.
|
||||
opacity: status === MessageDeliveryStatus.Read ? 1.0 : 0.3,
|
||||
};
|
||||
// Read by the peer → green (prominent); still only delivered → neutral gray.
|
||||
return status === MessageDeliveryStatus.Read
|
||||
? { color: color.Success.Main, opacity: 1.0, prominent: true }
|
||||
: { color: DOT_NEUTRAL, opacity: 1.0, prominent: false };
|
||||
}
|
||||
|
||||
return {
|
||||
color: `var(${cssColorMXID(senderId)})`,
|
||||
// Same logic for incoming dots: keep the author hash colour visible at
|
||||
// all times so group-DM authorship stays scannable; only modulate the
|
||||
// dot's brightness via opacity to mark «I've seen it» (0.3) vs fresh (1.0).
|
||||
opacity: haveSeen ? 0.3 : 1.0,
|
||||
};
|
||||
// Incoming: neutral gray, dimmed once we've read it (so a fresh unread
|
||||
// message still pops on the rail) — no author-hash colour any more.
|
||||
return { color: DOT_NEUTRAL, opacity: haveSeen ? 0.4 : 1.0, prominent: false };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,27 @@ export const useGetMemberPowerTag = (
|
|||
const creatorsTag = useRoomCreatorsTag();
|
||||
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(
|
||||
(userId) => {
|
||||
if (creators.has(userId)) {
|
||||
|
|
@ -24,9 +45,14 @@ export const useGetMemberPowerTag = (
|
|||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -21,15 +21,29 @@
|
|||
default is the light-theme value; `.dark-theme` overrides below. */
|
||||
--vojo-horseshoe-void: #d6d6e3;
|
||||
|
||||
/* Peer (not-own) bubble bg — every «чужое» message reshades to
|
||||
this, in 1-1 DMs, groups, channels alike. Light: slight off-white
|
||||
step from #ffffff. Dark override below. */
|
||||
/* Peer (not-own) bubble bg for the CHANNEL layout (groups / channels /
|
||||
thread drawer). The 1-1 Stream layout's incoming bubble does NOT use this
|
||||
— 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;
|
||||
/* Stream timeline rail (vertical line through dots) + day-divider
|
||||
horizontal segments. Light: hairline cool-grey; dark overrides
|
||||
below. */
|
||||
--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-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
|
@ -49,7 +63,14 @@
|
|||
--vojo-horseshoe-void: #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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue