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 { 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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}`,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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 &&
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue