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
8d343042b4
commit
f4d1fdcebc
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 { 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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
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 './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';
|
||||
|
||||
// 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue