feat(timeline): square image+video bubbles with username overlay, reactions outside bubble, edge-anchored mobile rail, horizontal day divider

This commit is contained in:
v.lagerev 2026-05-07 21:24:50 +03:00
parent 8d343042b4
commit f4d1fdcebc
12 changed files with 836 additions and 277 deletions

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { MouseEventHandler, createContext, useContext } from 'react';
import { MsgType } from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
import { Opts } from 'linkifyjs';
@ -20,6 +20,11 @@ import {
ReadPdfFile,
ReadTextFile,
RenderBody,
RenderImageContentProps,
RenderVideoContentProps,
StreamMediaCaption,
StreamMediaImage,
StreamMediaVideo,
ThumbnailContent,
UnsupportedContent,
VideoContent,
@ -31,6 +36,22 @@ import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common';
import { logMedia } from './message/attachment/streamMediaDebug';
// Threads the StreamLayout's mediaMode info from Message.tsx down to the
// image / video rendering branches below. Non-null only for media messages
// in the timeline; pin-menu / message-search leave it null and fall back
// to the legacy MImage / MVideo Attachment chrome.
export type StreamMediaContextValue = {
own: boolean;
username: string;
senderId: string;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu: MouseEventHandler<HTMLButtonElement>;
};
export const StreamMediaContext = createContext<StreamMediaContextValue | null>(null);
export const useStreamMediaContext = (): StreamMediaContextValue | null =>
useContext(StreamMediaContext);
type RenderMessageContentProps = {
displayName: string;
@ -58,6 +79,7 @@ export function RenderMessageContent({
linkifyOpts,
outlineAttachment,
}: RenderMessageContentProps) {
const streamMedia = useStreamMediaContext();
const renderUrlsPreview = (urls: string[]) => {
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
if (filteredUrls.length === 0) return undefined;
@ -72,9 +94,9 @@ export function RenderMessageContent({
const renderCaption = () => {
const content: IImageContent = getContent();
if (content.filename && content.filename !== content.body) {
return (
const captionNode = (
<MText
style={{ marginTop: config.space.S200 }}
style={streamMedia ? undefined : { marginTop: config.space.S200 }}
edited={edited}
content={content}
renderBody={(props) => (
@ -88,6 +110,10 @@ export function RenderMessageContent({
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
/>
);
if (streamMedia) {
return <div className={StreamMediaCaption}>{captionNode}</div>;
}
return captionNode;
}
return null;
};
@ -184,53 +210,90 @@ export function RenderMessageContent({
}
if (msgType === MsgType.Image) {
logMedia('RenderMessageContent', {
msgType,
streamMediaPresent: !!streamMedia,
branch: streamMedia ? 'StreamMediaImage' : 'MImage(legacy)',
});
const renderImageInside = (props: RenderImageContentProps) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
);
return (
<>
<MImage
content={getContent()}
renderImageContent={(props) => (
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
renderImage={(p) => <Image {...p} loading="lazy" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{streamMedia ? (
<StreamMediaImage
content={getContent()}
own={streamMedia.own}
overlay={streamMedia.username}
senderId={streamMedia.senderId}
onUsernameClick={streamMedia.onUsernameClick}
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
renderImageContent={renderImageInside}
/>
) : (
<MImage
content={getContent()}
renderImageContent={renderImageInside}
outlined={outlineAttachment}
/>
)}
{renderCaption()}
</>
);
}
if (msgType === MsgType.Video) {
logMedia('RenderMessageContent', {
msgType,
streamMediaPresent: !!streamMedia,
branch: streamMedia ? 'StreamMediaVideo' : 'MVideo(legacy)',
});
const renderVideoInside = ({ body, info, ...props }: RenderVideoContentProps) => (
<VideoContent
body={body}
info={info}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" decoding="async" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
);
return (
<>
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={({ body, info, ...props }) => (
<VideoContent
body={body}
info={info}
{...props}
renderThumbnail={
mediaAutoLoad
? () => (
<ThumbnailContent
info={info}
renderImage={(src) => (
<Image alt={body} title={body} src={src} loading="lazy" />
)}
/>
)
: undefined
}
renderVideo={(p) => <Video {...p} />}
/>
)}
outlined={outlineAttachment}
/>
{streamMedia ? (
<StreamMediaVideo
content={getContent()}
own={streamMedia.own}
overlay={streamMedia.username}
senderId={streamMedia.senderId}
onUsernameClick={streamMedia.onUsernameClick}
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
renderAsFile={renderFile}
renderVideoContent={renderVideoInside}
/>
) : (
<MVideo
content={getContent()}
renderAsFile={renderFile}
renderVideoContent={renderVideoInside}
outlined={outlineAttachment}
/>
)}
{renderCaption()}
</>
);
@ -253,6 +316,12 @@ export function RenderMessageContent({
}
if (msgType === MsgType.File) {
const fileMime = (getContent() as { info?: { mimetype?: string } }).info?.mimetype;
logMedia('RenderMessageContent', {
msgType,
branch: 'MFile(legacy)',
fileMime,
});
return renderFile();
}

View file

@ -174,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
);
}
type RenderImageContentProps = {
export type RenderImageContentProps = {
body: string;
filename?: string;
info?: IImageInfo & IThumbnailContent;
@ -218,7 +218,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
);
}
type RenderVideoContentProps = {
export type RenderVideoContentProps = {
body: string;
info: IVideoInfo & IThumbnailContent;
mimeType: string;

View file

@ -0,0 +1,108 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, toRem } from 'folds';
const StreamMediaCornerNotch = toRem(4);
const StreamMediaCornerRound = config.radii.R500;
export const StreamMediaBubble = recipe({
base: {
position: 'relative',
overflow: 'hidden',
backgroundColor: color.SurfaceVariant.Container,
maxWidth: '100%',
boxSizing: 'border-box',
flexShrink: 0,
// 1px frame drawn ABOVE the image via a pseudo-element. `inset
// box-shadow` would sit on the bg layer and get hidden by the
// `<img>` filling 100% × 100%; a real `border` would push the
// image's coordinate space and re-introduce the 1px chip-alignment
// offset we just removed. The pseudo sits at z-index 1 (above
// image's auto-stacked 0, below the username overlay at z 2),
// inherits the bubble's asymmetric border-radius, and is
// pointer-events:none so clicks pass through to the image.
selectors: {
'&::after': {
content: '""',
position: 'absolute',
inset: 0,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: 'inherit',
pointerEvents: 'none',
zIndex: 1,
},
},
},
variants: {
own: {
true: {
borderRadius: `${StreamMediaCornerNotch} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound}`,
},
false: {
borderRadius: `${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerRound} ${StreamMediaCornerNotch}`,
},
},
},
});
// 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.
export const StreamMediaCaption = style({
marginTop: toRem(4),
backgroundColor: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: StreamMediaCornerRound,
paddingLeft: config.space.S300,
paddingRight: config.space.S300,
paddingTop: config.space.S200,
paddingBottom: config.space.S200,
maxWidth: '100%',
width: 'fit-content',
boxSizing: 'border-box',
});

View file

@ -0,0 +1,61 @@
import React, { MouseEventHandler, ReactNode } from 'react';
import {
IImageContent,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../../types/matrix/common';
import { RenderImageContentProps } from '../MsgTypeRenderers';
import { StreamMediaShell } from './StreamMediaShell';
// Filename-shaped bodies (`IMG_20240501_140532.jpg`, `Screenshot_*`, plain
// 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 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) {
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}
>
{renderImageContent({
body: altFor(content.body),
info: imgInfo,
mimeType: imgInfo?.mimetype,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</StreamMediaShell>
);
}

View file

@ -0,0 +1,105 @@
import React, { MouseEventHandler, ReactNode, useRef } from 'react';
import { toRem } from 'folds';
import {
StreamMediaBubble,
StreamMediaUsernameLabel,
StreamMediaUsernameOverlay,
} from './StreamMedia.css';
import { logMedia, useMediaMeasureDebug } from './streamMediaDebug';
const STREAM_MEDIA_MAX_DIM = 320;
const STREAM_MEDIA_MIN_ASPECT = 3 / 4;
const STREAM_MEDIA_MAX_ASPECT = 4 / 3;
export type StreamMediaShellProps = {
naturalW?: number;
naturalH?: number;
own: boolean;
overlay?: ReactNode;
senderId?: string;
onUsernameClick?: MouseEventHandler<HTMLButtonElement>;
onUsernameContextMenu?: MouseEventHandler<HTMLButtonElement>;
children: ReactNode;
};
function computeBoxStyle(naturalW?: number, naturalH?: number): React.CSSProperties {
// Explicit pixel width + height — required so the inner content (folds
// ImageContent / VideoContent — `RelativeBase` with `height: 100%`) gets
// a definite parent height to resolve against. `max-width: 100%` from
// StreamMediaBubble + the surrounding StreamBubble's mediaMode
// `display: block; width: 100%` chain shrinks the bubble on narrow
// viewports; the small aspect drift this introduces is masked by
// `object-fit: cover` (image) / `contain` (video) on the inner element.
const naturalAspect = naturalW && naturalH ? naturalW / naturalH : NaN;
if (
!Number.isFinite(naturalAspect) ||
naturalAspect < STREAM_MEDIA_MIN_ASPECT ||
naturalAspect > STREAM_MEDIA_MAX_ASPECT
) {
return { width: toRem(STREAM_MEDIA_MAX_DIM), height: toRem(STREAM_MEDIA_MAX_DIM) };
}
if (naturalAspect >= 1) {
const w = Math.min(STREAM_MEDIA_MAX_DIM, naturalW!);
return { width: toRem(w), height: toRem(w / naturalAspect) };
}
const h = Math.min(STREAM_MEDIA_MAX_DIM, naturalH!);
return { width: toRem(h * naturalAspect), height: toRem(h) };
}
// 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) {
const bubbleRef = useRef<HTMLDivElement>(null);
const computedStyle = computeBoxStyle(naturalW, naturalH);
logMedia('StreamMediaShell', {
own,
naturalW,
naturalH,
overlayPresent: !!overlay,
senderId,
computedStyle: { ...computedStyle },
});
useMediaMeasureDebug(bubbleRef, [computedStyle.width, computedStyle.height]);
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>
);
}

View file

@ -0,0 +1,65 @@
import React, { MouseEventHandler, ReactNode } from 'react';
import {
IVideoContent,
MATRIX_SPOILER_PROPERTY_NAME,
MATRIX_SPOILER_REASON_PROPERTY_NAME,
} from '../../../../types/matrix/common';
import { getBlobSafeMimeType } from '../../../utils/mimeTypes';
import { RenderVideoContentProps } from '../MsgTypeRenderers';
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;
};
export function StreamMediaVideo({
content,
own,
overlay,
onUsernameClick,
onUsernameContextMenu,
senderId,
renderAsFile,
renderVideoContent,
}: StreamMediaVideoProps) {
const videoInfo = content.info;
const mxcUrl = content.file?.url ?? content.url;
const safeMimeType = getBlobSafeMimeType(videoInfo?.mimetype ?? '');
// Mirror MVideo's fallback: if mime / info is bad but we have a URL,
// render as a file attachment. Pin-menu / search go through the legacy
// MVideo path entirely.
if (!videoInfo || !safeMimeType.startsWith('video') || typeof mxcUrl !== 'string') {
if (mxcUrl) return <>{renderAsFile()}</>;
return null;
}
return (
<StreamMediaShell
naturalW={videoInfo.w}
naturalH={videoInfo.h}
own={own}
overlay={overlay}
senderId={senderId}
onUsernameClick={onUsernameClick}
onUsernameContextMenu={onUsernameContextMenu}
>
{renderVideoContent({
body: content.body || 'Video',
info: videoInfo,
mimeType: safeMimeType,
url: mxcUrl,
encInfo: content.file,
markedAsSpoiler: content[MATRIX_SPOILER_PROPERTY_NAME],
spoilerReason: content[MATRIX_SPOILER_REASON_PROPERTY_NAME],
})}
</StreamMediaShell>
);
}

View file

@ -1 +1,4 @@
export * from './Attachment';
export * from './StreamMediaImage';
export * from './StreamMediaVideo';
export { StreamMediaCaption } from './StreamMedia.css';

View file

@ -0,0 +1,123 @@
import { RefObject, useEffect } from 'react';
// Runtime opt-in via `localStorage.setItem('vojo_mediaDebug', '1')`. Note we
// do NOT gate on `import.meta.env.DEV` — that's `false` in any Vite
// production build (including the APK that Capacitor ships), so the debug
// would never fire on-device. The bundle cost of the logging strings is
// ~200 bytes, acceptable for the diagnostic value.
const LS_KEY = 'vojo_mediaDebug';
const enabled = (): boolean => {
try {
return typeof window !== 'undefined' && window.localStorage.getItem(LS_KEY) === '1';
} catch {
return false;
}
};
const safeJson = (v: unknown): string => {
try {
return JSON.stringify(v);
} catch {
return String(v);
}
};
export const logMedia = (label: string, payload: unknown): void => {
if (!enabled()) return;
// eslint-disable-next-line no-console
console.log(`[VOJO/${label}] ${safeJson(payload)}`);
};
export const useMediaMeasureDebug = (
ref: RefObject<HTMLElement | null>,
deps: unknown[]
): void => {
useEffect(() => {
if (!enabled()) return undefined;
const node = ref.current;
if (!node) return undefined;
const measure = () => {
const r = node.getBoundingClientRect();
const cs = window.getComputedStyle(node);
const img = node.querySelector('img');
const imgRect = img?.getBoundingClientRect();
const imgCS = img && window.getComputedStyle(img);
const root = document.body.getBoundingClientRect();
const chain: unknown[] = [];
let cur: HTMLElement | null = node;
let depth = 0;
while (cur && depth < 10) {
const cr = cur.getBoundingClientRect();
const ccs = window.getComputedStyle(cur);
chain.push({
depth,
tag: cur.tagName,
className: typeof cur.className === 'string' ? cur.className : '',
x: Math.round(cr.x),
right: Math.round(cr.right),
w: Math.round(cr.width),
overflow: ccs.overflow,
margin: `${ccs.marginLeft} ${ccs.marginRight}`,
padding: `${ccs.paddingLeft} ${ccs.paddingRight}`,
width: ccs.width,
maxWidth: ccs.maxWidth,
display: ccs.display,
});
cur = cur.parentElement;
depth += 1;
}
// eslint-disable-next-line no-console
console.log(
`[VOJO/StreamMediaShell/measure] ${safeJson({
bubbleRect: {
x: Math.round(r.x),
y: Math.round(r.y),
w: Math.round(r.width),
h: Math.round(r.height),
right: Math.round(r.right),
},
bubbleCS: {
width: cs.width,
height: cs.height,
maxWidth: cs.maxWidth,
display: cs.display,
position: cs.position,
overflow: cs.overflow,
border: cs.border,
boxSizing: cs.boxSizing,
},
viewport: {
innerWidth: window.innerWidth,
bodyWidth: Math.round(root.width),
},
img:
img && imgRect
? {
rect: {
x: Math.round(imgRect.x),
w: Math.round(imgRect.width),
h: Math.round(imgRect.height),
right: Math.round(imgRect.right),
},
naturalW: img.naturalWidth,
naturalH: img.naturalHeight,
cs: imgCS && {
width: imgCS.width,
height: imgCS.height,
objectFit: imgCS.objectFit,
},
}
: 'no-img',
ancestors: chain,
})}`
);
};
measure();
const img = node.querySelector('img');
if (!img) return undefined;
img.addEventListener('load', measure);
return () => img.removeEventListener('load', measure);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
};

View file

@ -6,9 +6,7 @@ import { useStreamLayoutDebug } from './streamDebug';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows.
// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus
// Message.tsx Message / Event MessageBase — share this single constant.
// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows.
export const STREAM_MESSAGE_SPACING = '400' as const;
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
@ -23,16 +21,6 @@ export const STREAM_MESSAGE_SPACING = '400' as const;
// └──┴─────────────────────────────────────────────────┘
// rail+dot bubble (time absolutely-positioned to the
// LEFT of the bubble, on the rail-time column)
//
// Read-state lives entirely on the dot now (own = Primary violet, opacity
// 0.3 → 1.0 when peer reads; incoming = author hash, opacity 1.0 → 0.3 once
// I read it). The legacy WhatsApp checkmark `<MessageStatus>` is intentionally
// not rendered in Stream — the dot already encodes that signal.
//
// Geometry constants live in layout.css.ts (StreamRailWidth, StreamDotSize).
// `time` and `header` are caller-controlled ReactNodes so Message.tsx keeps
// ownership of the timestamp formatting / sender Username component / e2ee
// indicators it already builds.
export type StreamLayoutProps = {
time?: ReactNode;
@ -43,36 +31,42 @@ export type StreamLayoutProps = {
header?: ReactNode;
railStart?: boolean;
railEnd?: boolean;
// Image messages: bubble bg/border/padding collapse so the
// StreamMediaImage child supplies the visible chrome.
mediaMode?: boolean;
// Reactions chip-row, rendered BELOW the bubble in the same content
// column — outside the bubble's bg/border so reactions read as
// floating chips on the page background, not as a part of the bubble.
reactions?: ReactNode;
};
// Stream day divider — used by RoomTimeline.tsx in place of the legacy
// TimelineDivider for DM rooms. Sits as a regular row in the timeline so the
// rail flows through it: rail-column has the date label (small mono uppercase),
// a slightly larger Fleet-soft dot anchors on the rail, and the rest of the
// row is a faint hairline. Matches stream-v2-dawn.jsx::DawnPhoneV3 line 73-78.
export type StreamDayDividerProps = {
label: ReactNode;
};
export const StreamDayDivider = as<'div', StreamDayDividerProps>(
({ className, label, ...props }, ref) => {
// Reads screen size internally instead of taking a prop so RoomTimeline
// doesn't have to thread it through. The day-divider must use the SAME
// `compact` value as message rows above and below or the rail and label
// would mis-align horizontally on desktop.
// Must match the `compact` value of message rows above/below — desktop
// marginLeft on the row root cascades to abs children only when both
// rows resolve to the same variant.
const compact = useScreenSizeContext() === ScreenSize.Mobile;
return (
<div
className={classNames(css.StreamDayRoot({ compact }), className)}
{...props}
role="separator"
aria-label={typeof label === 'string' ? label : undefined}
ref={ref}
>
{/* Rail segment under the row same recipe as message rows so the
rail flows continuously through the day boundary. */}
<span className={css.StreamRail} aria-hidden />
<div className={css.StreamDayLabel}>{label}</div>
<span className={css.StreamDayDot} aria-hidden />
<div className={css.StreamDayLine} aria-hidden />
{/* ` label ` line emanates right from the day-marker dot; the
rail-time column on the left is intentionally empty. */}
<div className={css.StreamDayLineWrap} aria-hidden>
<span className={css.StreamDayLineSegment} />
<span className={css.StreamDayLabel}>{label}</span>
<span className={css.StreamDayLineSegment} />
</div>
</div>
);
}
@ -90,6 +84,8 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
header,
railStart,
railEnd,
mediaMode,
reactions,
children,
...props
},
@ -104,9 +100,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
// Debug helper is dev-only and behind a localStorage opt-in (see
// streamDebug.ts). After P3c every timeline row goes through StreamLayout,
// so `active` is unconditionally true here.
// Dev-only, gated by localStorage opt-in (see streamDebug.ts).
useStreamLayoutDebug(
'message',
{
@ -136,26 +130,36 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
aria-hidden
ref={railRef}
/>
<div className={css.StreamBubble({ own: !!isOwn, compact: !!compact })} ref={bubbleRef}>
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
<span
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
aria-hidden
ref={dotRef}
<div className={css.StreamColumn}>
<div
className={css.StreamBubble({
own: !!isOwn,
compact: !!compact,
mediaMode: !!mediaMode,
})}
ref={bubbleRef}
>
<span className={css.StreamHeaderTime} ref={timeRef}>
{time}
</span>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
{header && (
<div className={css.StreamBubbleHeader} ref={headerRef}>
{header}
</div>
)}
{children}
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
aria-hidden
ref={dotRef}
>
<span
className={css.StreamDotFill}
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
/>
</span>
{header && (
<div className={css.StreamBubbleHeader} ref={headerRef}>
{header}
</div>
)}
{children}
</div>
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
</div>
</div>
);

View file

@ -139,94 +139,78 @@ export const UsernameBold = style({
});
// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b).
// Geometry follows stream-v2-dawn.jsx::DawnPhoneV3 (rail 60) and DawnDesktopV3
// (rail 84). We use a single 64px rail-column as a pragmatic middle: tested
// readable on mobile (>=320px) and not overweight on desktop. Tunable via
// runbook smoke.
const StreamRailWidth = toRem(64);
// Single X-axis source of truth: every rail line/dot center derives from this.
// Dots subtract half their own size; the 1px rail subtracts half its width.
const StreamRailCenterX = StreamRailWidth;
// Desktop = 64px rail. Mobile shrinks the rail and the time-rail gap via
// CSS-var overrides on StreamRoot/StreamDayRoot's `compact` variant so the
// timestamp text hugs the left edge of the screen.
const StreamRailWidthDesktop = toRem(64);
const StreamRailWidthMobile = toRem(48);
const RailWidthVar = createVar();
const StreamRailCenterX = RailWidthVar;
const StreamDotSize = toRem(9);
// Sysline dots are smaller than message dots so system breadcrumbs read
// lighter than ordinary message rows on the same rail.
const StreamSyslineDotSize = toRem(6);
// 1.1× the message-row dot (9px → 9.9px would be visually indistinguishable),
// rounded up so the day marker reads clearly larger than ordinary dots while
// staying on the rail-center geometry. Was 11px; bumped per design feedback.
// Slightly larger than message dots so day markers read as the visual
// anchor of the `─── Сегодня ───` line.
const StreamDayDotSize = toRem(12.1);
const StreamRailLineWidth = '1px';
const StreamBubbleBorderWidth = '1px';
const StreamTimeLineHeight = toRem(13);
const StreamRailBridgeY = config.space.S400;
// Vertical center of the message dot from the row's top edge — must equal the
// vertical center of the StreamBubbleHeader text line so timestamp / dot /
// sender-nickname share one baseline:
// = StreamRoot.paddingTop (S100)
// + bubble.borderTop (1px)
// + bubble.paddingTop (S200) — the header sits inside the bubble's content
// box, AFTER the padding
// + half of the header line (T200 / 2)
// The dot and timestamp are absolute-positioned children of StreamBubble (its
// padding-box is their containing block), so their CSS `top` must include the
// S200 paddingTop offset to land on the same baseline as the header text.
// Dot/timestamp Y must match the bubble-header text baseline:
// row paddingTop (S100) + bubble.borderTop (1px) + bubble.paddingTop (S200)
// + half of header line (T200 / 2). All three abs-positioned children land
// on the same baseline, regardless of bubble height.
const StreamMessageDotCenterY = `calc(${config.space.S100} + ${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
const StreamMessageRailEndHeight = `calc(${StreamRailBridgeY} + ${StreamMessageDotCenterY})`;
const StreamRailLineLeft = `calc(${StreamRailCenterX} - (${StreamRailLineWidth} / 2))`;
const StreamDotLeft = `calc(${StreamRailCenterX} - (${StreamDotSize} / 2))`;
const StreamDayDotLeft = `calc(${StreamRailCenterX} - (${StreamDayDotSize} / 2))`;
const StreamBubbleColumnStartX = `calc(${StreamRailCenterX} + ${config.space.S500})`;
// Padding-box-left of the bubble from the row's left edge. CSS positions abs
// children of `position: relative` parents against the parent's padding box
// (i.e. inside the border, outside the padding) — so we must NOT include
// paddingLeft (S300) here, even though the visible bubble content begins
// further in. Earlier revisions added `+ S300` and offset every header item
// by 12 px to the left of where the rail expected them.
// abs-positioned children resolve against the bubble's padding box, NOT
// content box — so paddingLeft (S300) does NOT enter this calc. Earlier
// revisions added it and offset every header item 12 px to the left.
const StreamBubblePaddingBoxLeftX = `calc(${StreamBubbleColumnStartX} + ${StreamBubbleBorderWidth})`;
const StreamHeaderRailCenterX = `calc(${StreamRailCenterX} - (${StreamBubblePaddingBoxLeftX}))`;
// Rail-to-bubble gap. Used as the grid `columnGap` on StreamRoot /
// StreamDayRoot so the bubble's left edge sits at rail.center +
// StreamRailGutter.
const StreamRailGutter = config.space.S500;
// Rail-to-timestamp gap. Distance between the visible text-right-edge of
// "10:30 PM" / day labels and the rail-line center. Tuned by eye against
// the bubble-side gap (StreamRailGutter = 20 px) — the timestamp reads
// closer to the dot than the bubble does, because text rights are sparse
// glyphs while the bubble has a solid border. 14 px is roughly half of
// what the symmetric 28 px construction looked like, picked from a smoke
// session — adjust with confidence, the geometry around it is robust to
// any reasonable value (see StreamTimeBoxWidth).
const StreamTimeRailGap = toRem(14);
// Distance between timestamp text-right-edge and the rail center. Mobile
// is tighter so "23:59" stays inside the viewport with ~4 px breathing
// room from the screen edge.
const StreamTimeRailGapDesktop = toRem(14);
const StreamTimeRailGapMobile = toRem(8);
const TimeRailGapVar = createVar();
const StreamTimeRailGap = TimeRailGapVar;
// Width buffer for timestamp / day-label elements. The rail-time column is
// visually 64 px wide (= StreamRailWidth), but constraining the timestamp
// element to exactly that width breaks the `paddingRight: StreamTimeRailGap`
// shift knob: "10:30 PM" in JetBrains Mono Variable 11px is ≈ 53 px, so as
// soon as paddingRight pushes content-box below ~53 px the text overflows
// and Chromium stops shifting the rendered right edge with paddingRight.
// Giving the element a width well above the longest expected timestamp
// keeps content-box > text-width at every reasonable paddingRight, so
// (element_right paddingRight) becomes a linear text-shift control.
// Element right edge stays anchored at rail.center via `left:` calc /
// `justify-self: end` (grid items); it overflows LEFT into row margin /
// page-nav area where there's empty space anyway.
// Width buffer for the timestamp element. Constraining the box to the
// rail-column width (~64 px) breaks `paddingRight` as a text-shift knob:
// once paddingRight pushes content-box below text width, Chromium stops
// shifting the rendered right edge. 140 px keeps content-box > text width
// at every reasonable paddingRight; element overflows LEFT into empty row
// margin.
const StreamTimeBoxWidth = toRem(140);
// 1cm desktop nudge — pushes the entire row block (rail + dot + bubble)
// right so the visible content sits comfortably away from the PageNav.
// Implemented as `marginLeft` on the row root so EVERY child (grid items
// AND absolute rail / dot spans) shifts together — abs-positioned children
// resolve their containing block from the row root's padding box, which
// moves with the row root, no per-child calc needed.
// Desktop nudge — shifts the whole row right so content clears PageNav.
// Applied as marginLeft on the row root; abs rail / dot children inherit
// the shift via the containing block, no per-child calc.
const StreamRowDesktopOffset = toRem(38);
// Shared by StreamRoot and StreamDayRoot — desktop default + compact-mobile
// override use the same two vars.
const StreamRowVarsDesktop = {
[RailWidthVar]: StreamRailWidthDesktop,
[TimeRailGapVar]: StreamTimeRailGapDesktop,
};
const StreamRowVarsMobile = {
[RailWidthVar]: StreamRailWidthMobile,
[TimeRailGapVar]: StreamTimeRailGapMobile,
};
export const StreamRoot = recipe({
base: {
vars: StreamRowVarsDesktop,
position: 'relative',
display: 'grid',
gridTemplateColumns: `${StreamRailWidth} 1fr`,
gridTemplateColumns: `${RailWidthVar} 1fr`,
alignItems: 'flex-start',
columnGap: StreamRailGutter,
paddingTop: config.space.S100,
@ -235,14 +219,14 @@ export const StreamRoot = recipe({
},
variants: {
compact: {
// Desktop: shift the whole row right via marginLeft. abs rail / dot
// children move with the row automatically; no per-child compensation.
false: {
marginLeft: StreamRowDesktopOffset,
},
// Mobile: no left shift, and stretch the bubble further to the right
// edge by zeroing StreamRoot's right padding.
false: { marginLeft: StreamRowDesktopOffset },
// Negate MessageBase's S400/S200 horizontal padding so the row spans
// edge-to-edge — gives the timestamp text its couple-mm-from-screen-
// edge anchor and lets the bubble stretch all the way to the right.
true: {
vars: StreamRowVarsMobile,
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0,
},
},
@ -280,20 +264,13 @@ globalStyle(`${StreamTimeColumn} time`, {
lineHeight: StreamTimeLineHeight,
});
// Per-message rail segment. Rendering the rail at the timeline level would
// require touching RoomTimeline.tsx (deferred to P3b). The negative top/bottom
// offsets bridge MessageBase's marginTop (default messageSpacing S400 = 1rem)
// so consecutive Stream rows read as one continuous line, not a stack of
// disconnected segments. Each row's rail extends 1rem above + 1rem below its
// own StreamRoot. The line colour is opaque (not alpha via `opacity`) so any
// small segment overlap cannot compound into darker grey patches.
// Per-row rail segment. Top/bottom extend ±S400 past the row so consecutive
// rails join visually across MessageBase's marginTop. Background is opaque
// (not alpha) so any segment overlap can't compound into darker patches.
export const StreamRail = style({
position: 'absolute',
top: `calc(-1 * ${StreamRailBridgeY})`,
bottom: `calc(-1 * ${StreamRailBridgeY})`,
// Positioned relative to the row root's padding box. The desktop offset is
// applied as marginLeft on the row root, so the row root itself shifts and
// the rail's `left: 63.5` follows automatically — no per-child calc needed.
left: StreamRailLineLeft,
width: StreamRailLineWidth,
background: color.Surface.ContainerLine,
@ -314,17 +291,14 @@ export const StreamRailSingle = style({
display: 'none',
});
// Dot is a two-layer span: outer Halo paints a solid bg-coloured disk + ring
// that fully masks the rail behind the dot (regardless of opacity); inner Fill
// carries the actual author colour and the read-state opacity. Without this
// split, opacity < 1.0 on the dot would let the rail line show through.
// Two-layer span: outer Halo paints a solid bg disk + ring that masks the
// rail line behind the dot regardless of opacity; inner Fill carries the
// author colour and the read-state opacity.
export const StreamDotHalo = style({
position: 'absolute',
width: StreamDotSize,
height: StreamDotSize,
borderRadius: '50%',
// Same as StreamRail — positioned in the row root's padding box, which
// already carries the desktop marginLeft shift, so just `StreamDotLeft`.
left: StreamDotLeft,
background: color.Surface.Container,
boxShadow: `0 0 0 3px ${color.Surface.Container}`,
@ -336,9 +310,6 @@ export const StreamDotHalo = style({
export const StreamSyslineDotHalo = style({
width: StreamSyslineDotSize,
height: StreamSyslineDotSize,
// Smaller sysline dot — re-centered on the rail. No row-offset compensation
// needed because the row root's marginLeft shifts the abs-children's
// containing block too.
left: `calc(${StreamRailCenterX} - (${StreamSyslineDotSize} / 2))`,
top: '50%',
transform: 'translateY(-50%)',
@ -353,10 +324,6 @@ export const StreamSyslineRailStart = style({
top: '50%',
});
// `top` here is measured from the bubble's padding-box-top (CSS abs-positioning
// rule). The header text starts AFTER bubble's S200 paddingTop, so we add
// S200 + T200/2 to land the dot's vertical centre on the header text baseline.
// Without S200 the dot would float 8px ABOVE the nickname / timestamp line.
export const StreamHeaderDotHalo = style({
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`,
@ -368,12 +335,32 @@ export const StreamDotFill = style({
inset: 0,
borderRadius: '50%',
pointerEvents: 'none',
// Smooth read↔unread transition (peer reads my message → dot fades up to
// full vibrant, I read an incoming message → dot fades down to grey).
// Without this the state flip is a hard step.
// Smooth read↔unread fade — without it the state flip reads as a hard step.
transition: 'opacity 220ms ease, filter 220ms ease',
});
// Wrapper for grid column 2 — holds the bubble and the reactions row stacked.
// `min-width: 0` lets fit-content bubbles shrink below their content's natural
// size when the grid track is narrower than the bubble (otherwise they'd
// overflow).
export const StreamColumn = style({
gridColumn: 2,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
});
// Reactions row sits below the bubble, outside its bg/border, so reactions
// read as floating chips. Aligns to the bubble's left edge by inheriting
// `align-items: flex-start` from StreamColumn. The inline marginTop on the
// `<Reactions>` callsites in RoomTimeline supplies the spacing — flex
// columns don't collapse margins so a wrapper margin would stack.
export const StreamReactions = style({
maxWidth: '100%',
minWidth: 0,
});
export const StreamBubble = recipe({
base: {
backgroundColor: color.SurfaceVariant.Container,
@ -385,33 +372,21 @@ export const StreamBubble = recipe({
maxWidth: toRem(720),
position: 'relative',
zIndex: 1,
gridColumn: 2,
},
variants: {
// Asymmetric notch — own: top-left flat, three corners R500.
// Incoming: mirrored.
own: {
true: {
// Asymmetric radius — own messages: top-left flat (4px), three other
// corners 12px. Matches stream-v2-dawn.jsx canon line 95 / 271
// (`borderRadius: '4px 12px 12px 12px'`). R500 = 0.75rem = 12px.
borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`,
},
false: {
// Incoming: top-right flat, three other corners 12px (mirrored).
borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`,
},
},
// Mobile (≤750px): bubble fills the full message column, matching
// stream-v2-dawn.jsx::DawnPhoneV3 line 92-101 where the bubble is a plain
// block element inside the flex content column. Desktop (>750px): bubble
// sizes to its content (canon DawnDesktopV3 line 269-272 sets
// `display: inline-block`), so short messages don't stretch to the column
// edge. Branched in JSX via useScreenSizeContext (no media queries — see
// docs/plans/dm_1x1_redesign.md §5.5).
//
// Horizontal padding is split per variant: mobile keeps the canon-tight
// S300 (=12 px) on each side; desktop bumps to ~15 px (≈+25 % padding,
// visible bubble ~5 % wider for the same content) so messages feel less
// cramped on a wide viewport.
// Mobile fills the message column (block 100%); desktop fits content
// (inline-block fit-content). Branched via useScreenSizeContext, not
// CSS media queries — see docs/plans/dm_1x1_redesign.md §5.5.
compact: {
true: {
display: 'block',
@ -427,10 +402,29 @@ export const StreamBubble = recipe({
paddingRight: toRem(15),
},
},
// 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',
border: 'none',
borderRadius: 0,
padding: 0,
display: 'block',
width: '100%',
},
},
},
defaultVariants: {
own: false,
compact: false,
mediaMode: false,
},
});
@ -449,15 +443,12 @@ export const StreamBubbleHeader = style({
export const StreamHeaderTime = style({
position: 'absolute',
// Match StreamHeaderDotHalo vertical anchor — see comment there. Timestamp,
// dot, and nickname must share one baseline, so all three derive from
// (bubble.paddingTop + T200 / 2) inside the bubble's padding box.
// Same vertical anchor as StreamHeaderDotHalo — time, dot, nickname
// share one baseline.
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
// Element right edge anchored at rail.center inside the bubble's padding
// box (StreamHeaderRailCenterX is rail.center expressed in that
// coordinate system). Width is the 140 px buffer (see StreamTimeBoxWidth
// comment), so the element overflows LEFT — that's intentional, the
// visual rail-time column is the right 64 px slice of this element.
// Element right edge anchored at rail.center; element overflows LEFT
// into the empty row margin. The visible rail-time column is the right
// ~64px slice of this 140px box.
left: `calc(${StreamHeaderRailCenterX} - ${StreamTimeBoxWidth})`,
width: StreamTimeBoxWidth,
// Time-side gap is StreamTimeRailGap, slightly wider than StreamRailGutter
@ -508,38 +499,24 @@ export const StreamSyslineBody = style({
minWidth: 0,
});
// Day divider as a Stream row — replaces the old TimelineDivider/Badge for DM
// rooms so the rail reads continuous through day boundaries. Matches
// stream-v2-dawn.jsx::DawnPhoneV3 line 73-78 (date label in rail-time column,
// slightly larger Fleet-violet dot ON the rail, faint horizontal divider line
// reaching out to the right of the dot).
//
// Implemented on the same grid as message rows so the day-dot shares the exact
// StreamRailCenterX with message/sysline dots. The day-row also paints its OWN rail segment via StreamRail
// (same recipe used by message rows) so the rail visually flows through the
// day-divider — without this the previous and next message-row rails leave a
// gap of `paddingTop + dotHeight + paddingBottom` (≈ 27px) here.
// Day divider row — paints its own rail segment so the rail flows
// continuously through the day boundary. All children are abs-positioned
// against this root, no grid items.
export const StreamDayRoot = recipe({
base: {
display: 'grid',
gridTemplateColumns: `${StreamRailWidth} 1fr`,
alignItems: 'center',
columnGap: StreamRailGutter,
vars: StreamRowVarsDesktop,
paddingTop: toRem(10),
paddingBottom: toRem(10),
paddingRight: config.space.S400,
position: 'relative',
},
variants: {
// Day-divider row mirrors the StreamRoot horizontal offset so the rail
// and date label stay flush with the message rows above and below.
// Uses marginLeft (not paddingLeft) for the same reason as StreamRoot —
// abs-positioned dot/line children inherit the shift automatically.
compact: {
false: {
marginLeft: StreamRowDesktopOffset,
},
false: { marginLeft: StreamRowDesktopOffset },
true: {
vars: StreamRowVarsMobile,
marginLeft: `calc(-1 * ${config.space.S400})`,
marginRight: `calc(-1 * ${config.space.S200})`,
paddingRight: 0,
},
},
@ -548,41 +525,21 @@ export const StreamDayRoot = recipe({
});
export const StreamDayLabel = style({
textAlign: 'right',
// Day label sits on the same time-side gap as message timestamps so all
// text on the rail-time column shares one right anchor.
paddingRight: StreamTimeRailGap,
// Same width-buffer + right-anchor pattern as StreamTimeColumn, so
// paddingRight reliably shifts the day label too.
width: StreamTimeBoxWidth,
justifySelf: 'end',
fontSize: toRem(12),
fontSize: toRem(11),
textTransform: 'uppercase',
letterSpacing: toRem(1),
fontWeight: 600,
color: color.Surface.OnContainer,
opacity: 0.55,
boxSizing: 'border-box',
// Russian "СЕГОДНЯ" / "ВЧЕРА" / full date strings can be wider than the
// 64px rail-time-column. Allow the label to overflow LEFT (text-align: right
// keeps the right edge anchored at the rail-gap), since there is empty
// marginLeft / paddingLeft space to bleed into. nowrap prevents wrapping
// to a second line which would break day-row geometry.
whiteSpace: 'nowrap',
overflow: 'visible',
flexShrink: 0,
fontFamily:
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
});
// Slightly larger dot than message-row dots, painted in Fleet violet — canon
// uses fleetSoft (#a59cff) for day markers. We use Primary.MainHover (the
// same soft violet from the Dawn palette) so the divider stands out against
// ordinary author dots without breaking the rail rhythm.
export const StreamDayDot = style({
position: 'absolute',
top: '50%',
// Row root's marginLeft already shifts the containing block on desktop —
// no per-child compensation needed.
left: StreamDayDotLeft,
width: StreamDayDotSize,
height: StreamDayDotSize,
@ -594,18 +551,26 @@ export const StreamDayDot = style({
zIndex: 2,
});
// Hairline that fills the rest of the row to the right of the dot. Starts at
// the rail center; the right anchor stays unchanged. Row root's marginLeft
// applies the desktop shift through the containing block.
export const StreamDayLine = style({
// `─── Сегодня ───` line — flex row with two flex:1 hairlines and the
// label centered between them. Starts past the dot's right edge so the dot
// reads as the anchor.
export const StreamDayLineWrap = style({
position: 'absolute',
top: '50%',
left: StreamRailCenterX,
left: `calc(${StreamRailCenterX} + (${StreamDayDotSize} / 2) + ${toRem(4)})`,
right: config.space.S400,
transform: 'translateY(-50%)',
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
zIndex: 0,
});
export const StreamDayLineSegment = style({
flex: 1,
height: 1,
background: color.Surface.ContainerLine,
transform: 'translateY(-50%)',
zIndex: 0,
minWidth: toRem(8),
});
export const MessageTextBody = recipe({

View file

@ -1132,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
msgType={mEvent.getContent().msgtype ?? ''}
>
{mEvent.isRedacted() ? (
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
@ -1232,6 +1233,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
legacyUsernameColor={isOneOnOne}
streamRailStart={streamRailStart}
streamRailEnd={streamRailEnd}
msgType={mEvent.getContent().msgtype ?? ''}
>
{(() => {
if (mEvent.isRedacted()) return <RedactedContent />;

View file

@ -27,12 +27,13 @@ import React, {
MouseEventHandler,
ReactNode,
useCallback,
useMemo,
useState,
} from 'react';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { useHover, useFocusWithin } from 'react-aria';
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import classNames from 'classnames';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
@ -44,6 +45,8 @@ import {
Username,
UsernameBold,
} from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -670,6 +673,11 @@ export type MessageProps = {
legacyUsernameColor?: boolean;
streamRailStart?: boolean;
streamRailEnd?: boolean;
// Snapshot of `mEvent.getContent().msgtype` from the caller. Threaded as
// a prop (not derived locally) so encrypted-then-decrypted events flip
// mediaMode reliably when EncryptedContent re-renders post-decrypt —
// local useState would race against the commit↔effect gap.
msgType?: string;
};
export const Message = as<'div', MessageProps>(
(
@ -699,6 +707,7 @@ export const Message = as<'div', MessageProps>(
legacyUsernameColor,
streamRailStart,
streamRailEnd,
msgType,
children,
...props
},
@ -728,6 +737,48 @@ export const Message = as<'div', MessageProps>(
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
// msgType comes from the parent — RoomTimeline reads
// `mEvent.getContent().msgtype` synchronously and re-evaluates inside
// EncryptedContent's render-prop, which re-runs on Decrypted. Avoiding
// a local useState here sidesteps the commit↔effect race where
// decryption fires between render and listener attach.
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
const mediaMode = isMediaMessage && !edit;
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
logMedia('Message', {
eventId: mEvent.getId(),
msgType,
isMediaMessage,
edit,
mediaMode,
isMobile,
screenSize,
});
}
const streamMediaCtx = useMemo(
() =>
mediaMode
? {
own: isOwnMessage,
username: isOwnMessage ? t('Direct.message_me_label') : senderDisplayName,
senderId,
onUsernameClick,
onUsernameContextMenu: onUserClick,
}
: null,
[
mediaMode,
isOwnMessage,
senderDisplayName,
senderId,
onUsernameClick,
onUserClick,
t,
]
);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
@ -746,7 +797,6 @@ export const Message = as<'div', MessageProps>(
) : (
children
)}
{reactions}
</Box>
);
@ -1050,26 +1100,30 @@ export const Message = as<'div', MessageProps>(
compact={isMobile}
railStart={streamRailStart}
railEnd={streamRailEnd}
mediaMode={mediaMode}
reactions={reactions}
header={
// Stream rows always expose the author line: it gives every dot a
// stable visual anchor and keeps grouped messages readable.
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
mediaMode ? undefined : (
<Username
as="button"
style={{ color: usernameColor ?? color.Primary.Main }}
data-user-id={senderId}
onContextMenu={onUserClick}
onClick={onUsernameClick}
>
<Text as="span" size="T200" truncate>
<UsernameBold>
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
</UsernameBold>
</Text>
</Username>
)
}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
<StreamMediaContext.Provider value={streamMediaCtx}>
{msgContentJSX}
</StreamMediaContext.Provider>
</StreamLayout>
</MessageBase>
);