feat(timeline): square image+video bubbles with username overlay, reactions outside bubble, edge-anchored mobile rail, horizontal day divider
This commit is contained in:
parent
ce308776cd
commit
997375b307
12 changed files with 836 additions and 277 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import React from 'react';
|
import React, { MouseEventHandler, 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';
|
||||||
|
|
@ -20,6 +20,11 @@ import {
|
||||||
ReadPdfFile,
|
ReadPdfFile,
|
||||||
ReadTextFile,
|
ReadTextFile,
|
||||||
RenderBody,
|
RenderBody,
|
||||||
|
RenderImageContentProps,
|
||||||
|
RenderVideoContentProps,
|
||||||
|
StreamMediaCaption,
|
||||||
|
StreamMediaImage,
|
||||||
|
StreamMediaVideo,
|
||||||
ThumbnailContent,
|
ThumbnailContent,
|
||||||
UnsupportedContent,
|
UnsupportedContent,
|
||||||
VideoContent,
|
VideoContent,
|
||||||
|
|
@ -31,6 +36,22 @@ import { PdfViewer } from './Pdf-viewer';
|
||||||
import { TextViewer } from './text-viewer';
|
import { TextViewer } from './text-viewer';
|
||||||
import { testMatrixTo } from '../plugins/matrix-to';
|
import { testMatrixTo } from '../plugins/matrix-to';
|
||||||
import { IImageContent } from '../../types/matrix/common';
|
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 = {
|
type RenderMessageContentProps = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -58,6 +79,7 @@ export function RenderMessageContent({
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
|
const streamMedia = useStreamMediaContext();
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||||
if (filteredUrls.length === 0) return undefined;
|
if (filteredUrls.length === 0) return undefined;
|
||||||
|
|
@ -72,9 +94,9 @@ export function RenderMessageContent({
|
||||||
const renderCaption = () => {
|
const renderCaption = () => {
|
||||||
const content: IImageContent = getContent();
|
const content: IImageContent = getContent();
|
||||||
if (content.filename && content.filename !== content.body) {
|
if (content.filename && content.filename !== content.body) {
|
||||||
return (
|
const captionNode = (
|
||||||
<MText
|
<MText
|
||||||
style={{ marginTop: config.space.S200 }}
|
style={streamMedia ? undefined : { marginTop: config.space.S200 }}
|
||||||
edited={edited}
|
edited={edited}
|
||||||
content={content}
|
content={content}
|
||||||
renderBody={(props) => (
|
renderBody={(props) => (
|
||||||
|
|
@ -88,6 +110,10 @@ export function RenderMessageContent({
|
||||||
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
renderUrlsPreview={urlPreview ? renderUrlsPreview : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
if (streamMedia) {
|
||||||
|
return <div className={StreamMediaCaption}>{captionNode}</div>;
|
||||||
|
}
|
||||||
|
return captionNode;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
@ -184,53 +210,90 @@ export function RenderMessageContent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.Image) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MImage
|
{streamMedia ? (
|
||||||
content={getContent()}
|
<StreamMediaImage
|
||||||
renderImageContent={(props) => (
|
content={getContent()}
|
||||||
<ImageContent
|
own={streamMedia.own}
|
||||||
{...props}
|
overlay={streamMedia.username}
|
||||||
autoPlay={mediaAutoLoad}
|
senderId={streamMedia.senderId}
|
||||||
renderImage={(p) => <Image {...p} loading="lazy" />}
|
onUsernameClick={streamMedia.onUsernameClick}
|
||||||
renderViewer={(p) => <ImageViewer {...p} />}
|
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||||
/>
|
renderImageContent={renderImageInside}
|
||||||
)}
|
/>
|
||||||
outlined={outlineAttachment}
|
) : (
|
||||||
/>
|
<MImage
|
||||||
|
content={getContent()}
|
||||||
|
renderImageContent={renderImageInside}
|
||||||
|
outlined={outlineAttachment}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{renderCaption()}
|
{renderCaption()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.Video) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<MVideo
|
{streamMedia ? (
|
||||||
content={getContent()}
|
<StreamMediaVideo
|
||||||
renderAsFile={renderFile}
|
content={getContent()}
|
||||||
renderVideoContent={({ body, info, ...props }) => (
|
own={streamMedia.own}
|
||||||
<VideoContent
|
overlay={streamMedia.username}
|
||||||
body={body}
|
senderId={streamMedia.senderId}
|
||||||
info={info}
|
onUsernameClick={streamMedia.onUsernameClick}
|
||||||
{...props}
|
onUsernameContextMenu={streamMedia.onUsernameContextMenu}
|
||||||
renderThumbnail={
|
renderAsFile={renderFile}
|
||||||
mediaAutoLoad
|
renderVideoContent={renderVideoInside}
|
||||||
? () => (
|
/>
|
||||||
<ThumbnailContent
|
) : (
|
||||||
info={info}
|
<MVideo
|
||||||
renderImage={(src) => (
|
content={getContent()}
|
||||||
<Image alt={body} title={body} src={src} loading="lazy" />
|
renderAsFile={renderFile}
|
||||||
)}
|
renderVideoContent={renderVideoInside}
|
||||||
/>
|
outlined={outlineAttachment}
|
||||||
)
|
/>
|
||||||
: undefined
|
)}
|
||||||
}
|
|
||||||
renderVideo={(p) => <Video {...p} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
outlined={outlineAttachment}
|
|
||||||
/>
|
|
||||||
{renderCaption()}
|
{renderCaption()}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -253,6 +316,12 @@ export function RenderMessageContent({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msgType === MsgType.File) {
|
if (msgType === MsgType.File) {
|
||||||
|
const fileMime = (getContent() as { info?: { mimetype?: string } }).info?.mimetype;
|
||||||
|
logMedia('RenderMessageContent', {
|
||||||
|
msgType,
|
||||||
|
branch: 'MFile(legacy)',
|
||||||
|
fileMime,
|
||||||
|
});
|
||||||
return renderFile();
|
return renderFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ export function MNotice({ edited, content, renderBody, renderUrlsPreview }: MNot
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderImageContentProps = {
|
export type RenderImageContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
info?: IImageInfo & IThumbnailContent;
|
info?: IImageInfo & IThumbnailContent;
|
||||||
|
|
@ -218,7 +218,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type RenderVideoContentProps = {
|
export type RenderVideoContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
info: IVideoInfo & IThumbnailContent;
|
info: IVideoInfo & IThumbnailContent;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
|
|
|
||||||
108
src/app/components/message/attachment/StreamMedia.css.ts
Normal file
108
src/app/components/message/attachment/StreamMedia.css.ts
Normal 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',
|
||||||
|
});
|
||||||
61
src/app/components/message/attachment/StreamMediaImage.tsx
Normal file
61
src/app/components/message/attachment/StreamMediaImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/app/components/message/attachment/StreamMediaShell.tsx
Normal file
105
src/app/components/message/attachment/StreamMediaShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
src/app/components/message/attachment/StreamMediaVideo.tsx
Normal file
65
src/app/components/message/attachment/StreamMediaVideo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1 +1,4 @@
|
||||||
export * from './Attachment';
|
export * from './Attachment';
|
||||||
|
export * from './StreamMediaImage';
|
||||||
|
export * from './StreamMediaVideo';
|
||||||
|
export { StreamMediaCaption } from './StreamMedia.css';
|
||||||
|
|
|
||||||
123
src/app/components/message/attachment/streamMediaDebug.ts
Normal file
123
src/app/components/message/attachment/streamMediaDebug.ts
Normal 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);
|
||||||
|
};
|
||||||
|
|
@ -6,9 +6,7 @@ import { useStreamLayoutDebug } from './streamDebug';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
|
||||||
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
||||||
// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows.
|
// layout.css.ts (StreamRailBridgeY = S400) match the gap between rows.
|
||||||
// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus
|
|
||||||
// Message.tsx Message / Event MessageBase — share this single constant.
|
|
||||||
export const STREAM_MESSAGE_SPACING = '400' as const;
|
export const STREAM_MESSAGE_SPACING = '400' as const;
|
||||||
|
|
||||||
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
|
// 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
|
// rail+dot bubble (time absolutely-positioned to the
|
||||||
// LEFT of the bubble, on the rail-time column)
|
// 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 = {
|
export type StreamLayoutProps = {
|
||||||
time?: ReactNode;
|
time?: ReactNode;
|
||||||
|
|
@ -43,36 +31,42 @@ export type StreamLayoutProps = {
|
||||||
header?: ReactNode;
|
header?: ReactNode;
|
||||||
railStart?: boolean;
|
railStart?: boolean;
|
||||||
railEnd?: 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 = {
|
export type StreamDayDividerProps = {
|
||||||
label: ReactNode;
|
label: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const StreamDayDivider = as<'div', StreamDayDividerProps>(
|
export const StreamDayDivider = as<'div', StreamDayDividerProps>(
|
||||||
({ className, label, ...props }, ref) => {
|
({ className, label, ...props }, ref) => {
|
||||||
// Reads screen size internally instead of taking a prop so RoomTimeline
|
// Must match the `compact` value of message rows above/below — desktop
|
||||||
// doesn't have to thread it through. The day-divider must use the SAME
|
// marginLeft on the row root cascades to abs children only when both
|
||||||
// `compact` value as message rows above and below or the rail and label
|
// rows resolve to the same variant.
|
||||||
// would mis-align horizontally on desktop.
|
|
||||||
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(css.StreamDayRoot({ compact }), className)}
|
className={classNames(css.StreamDayRoot({ compact }), className)}
|
||||||
{...props}
|
{...props}
|
||||||
|
role="separator"
|
||||||
|
aria-label={typeof label === 'string' ? label : undefined}
|
||||||
ref={ref}
|
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 />
|
<span className={css.StreamRail} aria-hidden />
|
||||||
<div className={css.StreamDayLabel}>{label}</div>
|
|
||||||
<span className={css.StreamDayDot} aria-hidden />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -90,6 +84,8 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
header,
|
header,
|
||||||
railStart,
|
railStart,
|
||||||
railEnd,
|
railEnd,
|
||||||
|
mediaMode,
|
||||||
|
reactions,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -104,9 +100,7 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
|
|
||||||
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
|
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
|
||||||
|
|
||||||
// Debug helper is dev-only and behind a localStorage opt-in (see
|
// Dev-only, gated by localStorage opt-in (see streamDebug.ts).
|
||||||
// streamDebug.ts). After P3c every timeline row goes through StreamLayout,
|
|
||||||
// so `active` is unconditionally true here.
|
|
||||||
useStreamLayoutDebug(
|
useStreamLayoutDebug(
|
||||||
'message',
|
'message',
|
||||||
{
|
{
|
||||||
|
|
@ -136,26 +130,36 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
||||||
aria-hidden
|
aria-hidden
|
||||||
ref={railRef}
|
ref={railRef}
|
||||||
/>
|
/>
|
||||||
<div className={css.StreamBubble({ own: !!isOwn, compact: !!compact })} ref={bubbleRef}>
|
<div className={css.StreamColumn}>
|
||||||
<span className={css.StreamHeaderTime} ref={timeRef}>
|
<div
|
||||||
{time}
|
className={css.StreamBubble({
|
||||||
</span>
|
own: !!isOwn,
|
||||||
<span
|
compact: !!compact,
|
||||||
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
|
mediaMode: !!mediaMode,
|
||||||
aria-hidden
|
})}
|
||||||
ref={dotRef}
|
ref={bubbleRef}
|
||||||
>
|
>
|
||||||
|
<span className={css.StreamHeaderTime} ref={timeRef}>
|
||||||
|
{time}
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
className={css.StreamDotFill}
|
className={classNames(css.StreamDotHalo, css.StreamHeaderDotHalo)}
|
||||||
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
aria-hidden
|
||||||
/>
|
ref={dotRef}
|
||||||
</span>
|
>
|
||||||
{header && (
|
<span
|
||||||
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
className={css.StreamDotFill}
|
||||||
{header}
|
style={{ backgroundColor: dotColor, opacity: dotOpacity }}
|
||||||
</div>
|
/>
|
||||||
)}
|
</span>
|
||||||
{children}
|
{header && (
|
||||||
|
<div className={css.StreamBubbleHeader} ref={headerRef}>
|
||||||
|
{header}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{reactions && <div className={css.StreamReactions}>{reactions}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -139,94 +139,78 @@ export const UsernameBold = style({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b).
|
// Stream layout (DM redesign — see docs/plans/dm_1x1_redesign.md §6.5b).
|
||||||
// Geometry follows stream-v2-dawn.jsx::DawnPhoneV3 (rail 60) and DawnDesktopV3
|
// Desktop = 64px rail. Mobile shrinks the rail and the time-rail gap via
|
||||||
// (rail 84). We use a single 64px rail-column as a pragmatic middle: tested
|
// CSS-var overrides on StreamRoot/StreamDayRoot's `compact` variant so the
|
||||||
// readable on mobile (>=320px) and not overweight on desktop. Tunable via
|
// timestamp text hugs the left edge of the screen.
|
||||||
// runbook smoke.
|
const StreamRailWidthDesktop = toRem(64);
|
||||||
const StreamRailWidth = toRem(64);
|
const StreamRailWidthMobile = toRem(48);
|
||||||
// Single X-axis source of truth: every rail line/dot center derives from this.
|
const RailWidthVar = createVar();
|
||||||
// Dots subtract half their own size; the 1px rail subtracts half its width.
|
const StreamRailCenterX = RailWidthVar;
|
||||||
const StreamRailCenterX = StreamRailWidth;
|
|
||||||
const StreamDotSize = toRem(9);
|
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);
|
const StreamSyslineDotSize = toRem(6);
|
||||||
// 1.1× the message-row dot (9px → 9.9px would be visually indistinguishable),
|
// Slightly larger than message dots so day markers read as the visual
|
||||||
// rounded up so the day marker reads clearly larger than ordinary dots while
|
// anchor of the `─── Сегодня ───` line.
|
||||||
// staying on the rail-center geometry. Was 11px; bumped per design feedback.
|
|
||||||
const StreamDayDotSize = toRem(12.1);
|
const StreamDayDotSize = toRem(12.1);
|
||||||
const StreamRailLineWidth = '1px';
|
const StreamRailLineWidth = '1px';
|
||||||
const StreamBubbleBorderWidth = '1px';
|
const StreamBubbleBorderWidth = '1px';
|
||||||
const StreamTimeLineHeight = toRem(13);
|
const StreamTimeLineHeight = toRem(13);
|
||||||
const StreamRailBridgeY = config.space.S400;
|
const StreamRailBridgeY = config.space.S400;
|
||||||
// Vertical center of the message dot from the row's top edge — must equal the
|
// Dot/timestamp Y must match the bubble-header text baseline:
|
||||||
// vertical center of the StreamBubbleHeader text line so timestamp / dot /
|
// row paddingTop (S100) + bubble.borderTop (1px) + bubble.paddingTop (S200)
|
||||||
// sender-nickname share one baseline:
|
// + half of header line (T200 / 2). All three abs-positioned children land
|
||||||
// = StreamRoot.paddingTop (S100)
|
// on the same baseline, regardless of bubble height.
|
||||||
// + 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.
|
|
||||||
const StreamMessageDotCenterY = `calc(${config.space.S100} + ${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
|
const StreamMessageDotCenterY = `calc(${config.space.S100} + ${StreamBubbleBorderWidth} + ${config.space.S200} + (${config.lineHeight.T200} / 2))`;
|
||||||
const StreamMessageRailEndHeight = `calc(${StreamRailBridgeY} + ${StreamMessageDotCenterY})`;
|
const StreamMessageRailEndHeight = `calc(${StreamRailBridgeY} + ${StreamMessageDotCenterY})`;
|
||||||
const StreamRailLineLeft = `calc(${StreamRailCenterX} - (${StreamRailLineWidth} / 2))`;
|
const StreamRailLineLeft = `calc(${StreamRailCenterX} - (${StreamRailLineWidth} / 2))`;
|
||||||
const StreamDotLeft = `calc(${StreamRailCenterX} - (${StreamDotSize} / 2))`;
|
const StreamDotLeft = `calc(${StreamRailCenterX} - (${StreamDotSize} / 2))`;
|
||||||
const StreamDayDotLeft = `calc(${StreamRailCenterX} - (${StreamDayDotSize} / 2))`;
|
const StreamDayDotLeft = `calc(${StreamRailCenterX} - (${StreamDayDotSize} / 2))`;
|
||||||
const StreamBubbleColumnStartX = `calc(${StreamRailCenterX} + ${config.space.S500})`;
|
const StreamBubbleColumnStartX = `calc(${StreamRailCenterX} + ${config.space.S500})`;
|
||||||
// Padding-box-left of the bubble from the row's left edge. CSS positions abs
|
// abs-positioned children resolve against the bubble's padding box, NOT
|
||||||
// children of `position: relative` parents against the parent's padding box
|
// content box — so paddingLeft (S300) does NOT enter this calc. Earlier
|
||||||
// (i.e. inside the border, outside the padding) — so we must NOT include
|
// revisions added it and offset every header item 12 px to the left.
|
||||||
// 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.
|
|
||||||
const StreamBubblePaddingBoxLeftX = `calc(${StreamBubbleColumnStartX} + ${StreamBubbleBorderWidth})`;
|
const StreamBubblePaddingBoxLeftX = `calc(${StreamBubbleColumnStartX} + ${StreamBubbleBorderWidth})`;
|
||||||
const StreamHeaderRailCenterX = `calc(${StreamRailCenterX} - (${StreamBubblePaddingBoxLeftX}))`;
|
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;
|
const StreamRailGutter = config.space.S500;
|
||||||
|
|
||||||
// Rail-to-timestamp gap. Distance between the visible text-right-edge of
|
// Distance between timestamp text-right-edge and the rail center. Mobile
|
||||||
// "10:30 PM" / day labels and the rail-line center. Tuned by eye against
|
// is tighter so "23:59" stays inside the viewport with ~4 px breathing
|
||||||
// the bubble-side gap (StreamRailGutter = 20 px) — the timestamp reads
|
// room from the screen edge.
|
||||||
// closer to the dot than the bubble does, because text rights are sparse
|
const StreamTimeRailGapDesktop = toRem(14);
|
||||||
// glyphs while the bubble has a solid border. 14 px is roughly half of
|
const StreamTimeRailGapMobile = toRem(8);
|
||||||
// what the symmetric 28 px construction looked like, picked from a smoke
|
const TimeRailGapVar = createVar();
|
||||||
// session — adjust with confidence, the geometry around it is robust to
|
const StreamTimeRailGap = TimeRailGapVar;
|
||||||
// any reasonable value (see StreamTimeBoxWidth).
|
|
||||||
const StreamTimeRailGap = toRem(14);
|
|
||||||
|
|
||||||
// Width buffer for timestamp / day-label elements. The rail-time column is
|
// Width buffer for the timestamp element. Constraining the box to the
|
||||||
// visually 64 px wide (= StreamRailWidth), but constraining the timestamp
|
// rail-column width (~64 px) breaks `paddingRight` as a text-shift knob:
|
||||||
// element to exactly that width breaks the `paddingRight: StreamTimeRailGap`
|
// once paddingRight pushes content-box below text width, Chromium stops
|
||||||
// shift knob: "10:30 PM" in JetBrains Mono Variable 11px is ≈ 53 px, so as
|
// shifting the rendered right edge. 140 px keeps content-box > text width
|
||||||
// soon as paddingRight pushes content-box below ~53 px the text overflows
|
// at every reasonable paddingRight; element overflows LEFT into empty row
|
||||||
// and Chromium stops shifting the rendered right edge with paddingRight.
|
// margin.
|
||||||
// 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.
|
|
||||||
const StreamTimeBoxWidth = toRem(140);
|
const StreamTimeBoxWidth = toRem(140);
|
||||||
|
|
||||||
// 1cm desktop nudge — pushes the entire row block (rail + dot + bubble)
|
// Desktop nudge — shifts the whole row right so content clears PageNav.
|
||||||
// right so the visible content sits comfortably away from the PageNav.
|
// Applied as marginLeft on the row root; abs rail / dot children inherit
|
||||||
// Implemented as `marginLeft` on the row root so EVERY child (grid items
|
// the shift via the containing block, no per-child calc.
|
||||||
// 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.
|
|
||||||
const StreamRowDesktopOffset = toRem(38);
|
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({
|
export const StreamRoot = recipe({
|
||||||
base: {
|
base: {
|
||||||
|
vars: StreamRowVarsDesktop,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: `${StreamRailWidth} 1fr`,
|
gridTemplateColumns: `${RailWidthVar} 1fr`,
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
columnGap: StreamRailGutter,
|
columnGap: StreamRailGutter,
|
||||||
paddingTop: config.space.S100,
|
paddingTop: config.space.S100,
|
||||||
|
|
@ -235,14 +219,14 @@ export const StreamRoot = recipe({
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
compact: {
|
compact: {
|
||||||
// Desktop: shift the whole row right via marginLeft. abs rail / dot
|
false: { marginLeft: StreamRowDesktopOffset },
|
||||||
// children move with the row automatically; no per-child compensation.
|
// Negate MessageBase's S400/S200 horizontal padding so the row spans
|
||||||
false: {
|
// edge-to-edge — gives the timestamp text its couple-mm-from-screen-
|
||||||
marginLeft: StreamRowDesktopOffset,
|
// edge anchor and lets the bubble stretch all the way to the right.
|
||||||
},
|
|
||||||
// Mobile: no left shift, and stretch the bubble further to the right
|
|
||||||
// edge by zeroing StreamRoot's right padding.
|
|
||||||
true: {
|
true: {
|
||||||
|
vars: StreamRowVarsMobile,
|
||||||
|
marginLeft: `calc(-1 * ${config.space.S400})`,
|
||||||
|
marginRight: `calc(-1 * ${config.space.S200})`,
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -280,20 +264,13 @@ globalStyle(`${StreamTimeColumn} time`, {
|
||||||
lineHeight: StreamTimeLineHeight,
|
lineHeight: StreamTimeLineHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Per-message rail segment. Rendering the rail at the timeline level would
|
// Per-row rail segment. Top/bottom extend ±S400 past the row so consecutive
|
||||||
// require touching RoomTimeline.tsx (deferred to P3b). The negative top/bottom
|
// rails join visually across MessageBase's marginTop. Background is opaque
|
||||||
// offsets bridge MessageBase's marginTop (default messageSpacing S400 = 1rem)
|
// (not alpha) so any segment overlap can't compound into darker patches.
|
||||||
// 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.
|
|
||||||
export const StreamRail = style({
|
export const StreamRail = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: `calc(-1 * ${StreamRailBridgeY})`,
|
top: `calc(-1 * ${StreamRailBridgeY})`,
|
||||||
bottom: `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,
|
left: StreamRailLineLeft,
|
||||||
width: StreamRailLineWidth,
|
width: StreamRailLineWidth,
|
||||||
background: color.Surface.ContainerLine,
|
background: color.Surface.ContainerLine,
|
||||||
|
|
@ -314,17 +291,14 @@ export const StreamRailSingle = style({
|
||||||
display: 'none',
|
display: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dot is a two-layer span: outer Halo paints a solid bg-coloured disk + ring
|
// Two-layer span: outer Halo paints a solid bg disk + ring that masks the
|
||||||
// that fully masks the rail behind the dot (regardless of opacity); inner Fill
|
// rail line behind the dot regardless of opacity; inner Fill carries the
|
||||||
// carries the actual author colour and the read-state opacity. Without this
|
// author colour and the read-state opacity.
|
||||||
// split, opacity < 1.0 on the dot would let the rail line show through.
|
|
||||||
export const StreamDotHalo = style({
|
export const StreamDotHalo = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
width: StreamDotSize,
|
width: StreamDotSize,
|
||||||
height: StreamDotSize,
|
height: StreamDotSize,
|
||||||
borderRadius: '50%',
|
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,
|
left: StreamDotLeft,
|
||||||
background: color.Surface.Container,
|
background: color.Surface.Container,
|
||||||
boxShadow: `0 0 0 3px ${color.Surface.Container}`,
|
boxShadow: `0 0 0 3px ${color.Surface.Container}`,
|
||||||
|
|
@ -336,9 +310,6 @@ export const StreamDotHalo = style({
|
||||||
export const StreamSyslineDotHalo = style({
|
export const StreamSyslineDotHalo = style({
|
||||||
width: StreamSyslineDotSize,
|
width: StreamSyslineDotSize,
|
||||||
height: 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))`,
|
left: `calc(${StreamRailCenterX} - (${StreamSyslineDotSize} / 2))`,
|
||||||
top: '50%',
|
top: '50%',
|
||||||
transform: 'translateY(-50%)',
|
transform: 'translateY(-50%)',
|
||||||
|
|
@ -353,10 +324,6 @@ export const StreamSyslineRailStart = style({
|
||||||
top: '50%',
|
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({
|
export const StreamHeaderDotHalo = style({
|
||||||
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
|
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
|
||||||
left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`,
|
left: `calc(${StreamHeaderRailCenterX} - (${StreamDotSize} / 2))`,
|
||||||
|
|
@ -368,12 +335,32 @@ export const StreamDotFill = style({
|
||||||
inset: 0,
|
inset: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
// Smooth read↔unread transition (peer reads my message → dot fades up to
|
// Smooth read↔unread fade — without it the state flip reads as a hard step.
|
||||||
// full vibrant, I read an incoming message → dot fades down to grey).
|
|
||||||
// Without this the state flip is a hard step.
|
|
||||||
transition: 'opacity 220ms ease, filter 220ms ease',
|
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({
|
export const StreamBubble = recipe({
|
||||||
base: {
|
base: {
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
|
@ -385,33 +372,21 @@ export const StreamBubble = recipe({
|
||||||
maxWidth: toRem(720),
|
maxWidth: toRem(720),
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
gridColumn: 2,
|
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
|
// Asymmetric notch — own: top-left flat, three corners R500.
|
||||||
|
// Incoming: mirrored.
|
||||||
own: {
|
own: {
|
||||||
true: {
|
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}`,
|
borderRadius: `${toRem(4)} ${config.radii.R500} ${config.radii.R500} ${config.radii.R500}`,
|
||||||
},
|
},
|
||||||
false: {
|
false: {
|
||||||
// Incoming: top-right flat, three other corners 12px (mirrored).
|
|
||||||
borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`,
|
borderRadius: `${config.radii.R500} ${config.radii.R500} ${config.radii.R500} ${toRem(4)}`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Mobile (≤750px): bubble fills the full message column, matching
|
// Mobile fills the message column (block 100%); desktop fits content
|
||||||
// stream-v2-dawn.jsx::DawnPhoneV3 line 92-101 where the bubble is a plain
|
// (inline-block fit-content). Branched via useScreenSizeContext, not
|
||||||
// block element inside the flex content column. Desktop (>750px): bubble
|
// CSS media queries — see docs/plans/dm_1x1_redesign.md §5.5.
|
||||||
// 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.
|
|
||||||
compact: {
|
compact: {
|
||||||
true: {
|
true: {
|
||||||
display: 'block',
|
display: 'block',
|
||||||
|
|
@ -427,10 +402,29 @@ export const StreamBubble = recipe({
|
||||||
paddingRight: toRem(15),
|
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: {
|
defaultVariants: {
|
||||||
own: false,
|
own: false,
|
||||||
compact: false,
|
compact: false,
|
||||||
|
mediaMode: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -449,15 +443,12 @@ export const StreamBubbleHeader = style({
|
||||||
|
|
||||||
export const StreamHeaderTime = style({
|
export const StreamHeaderTime = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
// Match StreamHeaderDotHalo vertical anchor — see comment there. Timestamp,
|
// Same vertical anchor as StreamHeaderDotHalo — time, dot, nickname
|
||||||
// dot, and nickname must share one baseline, so all three derive from
|
// share one baseline.
|
||||||
// (bubble.paddingTop + T200 / 2) inside the bubble's padding box.
|
|
||||||
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
|
top: `calc(${config.space.S200} + (${config.lineHeight.T200} / 2))`,
|
||||||
// Element right edge anchored at rail.center inside the bubble's padding
|
// Element right edge anchored at rail.center; element overflows LEFT
|
||||||
// box (StreamHeaderRailCenterX is rail.center expressed in that
|
// into the empty row margin. The visible rail-time column is the right
|
||||||
// coordinate system). Width is the 140 px buffer (see StreamTimeBoxWidth
|
// ~64px slice of this 140px box.
|
||||||
// comment), so the element overflows LEFT — that's intentional, the
|
|
||||||
// visual rail-time column is the right 64 px slice of this element.
|
|
||||||
left: `calc(${StreamHeaderRailCenterX} - ${StreamTimeBoxWidth})`,
|
left: `calc(${StreamHeaderRailCenterX} - ${StreamTimeBoxWidth})`,
|
||||||
width: StreamTimeBoxWidth,
|
width: StreamTimeBoxWidth,
|
||||||
// Time-side gap is StreamTimeRailGap, slightly wider than StreamRailGutter
|
// Time-side gap is StreamTimeRailGap, slightly wider than StreamRailGutter
|
||||||
|
|
@ -508,38 +499,24 @@ export const StreamSyslineBody = style({
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Day divider as a Stream row — replaces the old TimelineDivider/Badge for DM
|
// Day divider row — paints its own rail segment so the rail flows
|
||||||
// rooms so the rail reads continuous through day boundaries. Matches
|
// continuously through the day boundary. All children are abs-positioned
|
||||||
// stream-v2-dawn.jsx::DawnPhoneV3 line 73-78 (date label in rail-time column,
|
// against this root, no grid items.
|
||||||
// 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.
|
|
||||||
export const StreamDayRoot = recipe({
|
export const StreamDayRoot = recipe({
|
||||||
base: {
|
base: {
|
||||||
display: 'grid',
|
vars: StreamRowVarsDesktop,
|
||||||
gridTemplateColumns: `${StreamRailWidth} 1fr`,
|
|
||||||
alignItems: 'center',
|
|
||||||
columnGap: StreamRailGutter,
|
|
||||||
paddingTop: toRem(10),
|
paddingTop: toRem(10),
|
||||||
paddingBottom: toRem(10),
|
paddingBottom: toRem(10),
|
||||||
paddingRight: config.space.S400,
|
paddingRight: config.space.S400,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
variants: {
|
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: {
|
compact: {
|
||||||
false: {
|
false: { marginLeft: StreamRowDesktopOffset },
|
||||||
marginLeft: StreamRowDesktopOffset,
|
|
||||||
},
|
|
||||||
true: {
|
true: {
|
||||||
|
vars: StreamRowVarsMobile,
|
||||||
|
marginLeft: `calc(-1 * ${config.space.S400})`,
|
||||||
|
marginRight: `calc(-1 * ${config.space.S200})`,
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -548,41 +525,21 @@ export const StreamDayRoot = recipe({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StreamDayLabel = style({
|
export const StreamDayLabel = style({
|
||||||
textAlign: 'right',
|
fontSize: toRem(11),
|
||||||
// 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),
|
|
||||||
textTransform: 'uppercase',
|
textTransform: 'uppercase',
|
||||||
letterSpacing: toRem(1),
|
letterSpacing: toRem(1),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
opacity: 0.55,
|
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',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'visible',
|
flexShrink: 0,
|
||||||
fontFamily:
|
fontFamily:
|
||||||
'"JetBrains Mono Variable", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
'"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({
|
export const StreamDayDot = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
// Row root's marginLeft already shifts the containing block on desktop —
|
|
||||||
// no per-child compensation needed.
|
|
||||||
left: StreamDayDotLeft,
|
left: StreamDayDotLeft,
|
||||||
width: StreamDayDotSize,
|
width: StreamDayDotSize,
|
||||||
height: StreamDayDotSize,
|
height: StreamDayDotSize,
|
||||||
|
|
@ -594,18 +551,26 @@ export const StreamDayDot = style({
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hairline that fills the rest of the row to the right of the dot. Starts at
|
// `─── Сегодня ───` line — flex row with two flex:1 hairlines and the
|
||||||
// the rail center; the right anchor stays unchanged. Row root's marginLeft
|
// label centered between them. Starts past the dot's right edge so the dot
|
||||||
// applies the desktop shift through the containing block.
|
// reads as the anchor.
|
||||||
export const StreamDayLine = style({
|
export const StreamDayLineWrap = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '50%',
|
top: '50%',
|
||||||
left: StreamRailCenterX,
|
left: `calc(${StreamRailCenterX} + (${StreamDayDotSize} / 2) + ${toRem(4)})`,
|
||||||
right: config.space.S400,
|
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,
|
height: 1,
|
||||||
background: color.Surface.ContainerLine,
|
background: color.Surface.ContainerLine,
|
||||||
transform: 'translateY(-50%)',
|
minWidth: toRem(8),
|
||||||
zIndex: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const MessageTextBody = recipe({
|
export const MessageTextBody = recipe({
|
||||||
|
|
|
||||||
|
|
@ -1132,6 +1132,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
legacyUsernameColor={isOneOnOne}
|
legacyUsernameColor={isOneOnOne}
|
||||||
streamRailStart={streamRailStart}
|
streamRailStart={streamRailStart}
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1232,6 +1233,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
legacyUsernameColor={isOneOnOne}
|
legacyUsernameColor={isOneOnOne}
|
||||||
streamRailStart={streamRailStart}
|
streamRailStart={streamRailStart}
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,13 @@ import React, {
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useHover, useFocusWithin } from 'react-aria';
|
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 { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
|
|
@ -44,6 +45,8 @@ import {
|
||||||
Username,
|
Username,
|
||||||
UsernameBold,
|
UsernameBold,
|
||||||
} from '../../../components/message';
|
} from '../../../components/message';
|
||||||
|
import { StreamMediaContext } from '../../../components/RenderMessageContent';
|
||||||
|
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
|
||||||
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
@ -670,6 +673,11 @@ export type MessageProps = {
|
||||||
legacyUsernameColor?: boolean;
|
legacyUsernameColor?: boolean;
|
||||||
streamRailStart?: boolean;
|
streamRailStart?: boolean;
|
||||||
streamRailEnd?: 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>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -699,6 +707,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
legacyUsernameColor,
|
legacyUsernameColor,
|
||||||
streamRailStart,
|
streamRailStart,
|
||||||
streamRailEnd,
|
streamRailEnd,
|
||||||
|
msgType,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -728,6 +737,48 @@ export const Message = as<'div', MessageProps>(
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const isMobile = screenSize === ScreenSize.Mobile;
|
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 = (
|
const msgContentJSX = (
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||||
{reply}
|
{reply}
|
||||||
|
|
@ -746,7 +797,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
) : (
|
) : (
|
||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
{reactions}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1050,26 +1100,30 @@ export const Message = as<'div', MessageProps>(
|
||||||
compact={isMobile}
|
compact={isMobile}
|
||||||
railStart={streamRailStart}
|
railStart={streamRailStart}
|
||||||
railEnd={streamRailEnd}
|
railEnd={streamRailEnd}
|
||||||
|
mediaMode={mediaMode}
|
||||||
|
reactions={reactions}
|
||||||
header={
|
header={
|
||||||
// Stream rows always expose the author line: it gives every dot a
|
mediaMode ? undefined : (
|
||||||
// stable visual anchor and keeps grouped messages readable.
|
<Username
|
||||||
<Username
|
as="button"
|
||||||
as="button"
|
style={{ color: usernameColor ?? color.Primary.Main }}
|
||||||
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>
|
||||||
<Text as="span" size="T200" truncate>
|
<UsernameBold>
|
||||||
<UsernameBold>
|
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
||||||
{isOwnMessage ? t('Direct.message_me_label') : senderDisplayName}
|
</UsernameBold>
|
||||||
</UsernameBold>
|
</Text>
|
||||||
</Text>
|
</Username>
|
||||||
</Username>
|
)
|
||||||
}
|
}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{msgContentJSX}
|
<StreamMediaContext.Provider value={streamMediaCtx}>
|
||||||
|
{msgContentJSX}
|
||||||
|
</StreamMediaContext.Provider>
|
||||||
</StreamLayout>
|
</StreamLayout>
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue