vojo/src/app/components/message/attachment/StreamMediaShell.tsx

105 lines
3.4 KiB
TypeScript

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>
);
}