feat(media-viewer): atom-driven horseshoe shell over chat replacing Overlay modal for image+video with anchor-aware pinch/wheel zoom and swipe prev/next
This commit is contained in:
parent
4654836092
commit
663aece487
15 changed files with 2046 additions and 28 deletions
|
|
@ -65,6 +65,12 @@ type RenderMessageContentProps = {
|
||||||
htmlReactParserOptions: HTMLReactParserOptions;
|
htmlReactParserOptions: HTMLReactParserOptions;
|
||||||
linkifyOpts: Opts;
|
linkifyOpts: Opts;
|
||||||
outlineAttachment?: boolean;
|
outlineAttachment?: boolean;
|
||||||
|
// Threaded into `ImageContent` so its onClick can open the new
|
||||||
|
// atom-driven horseshoe media viewer instead of the legacy
|
||||||
|
// `<Overlay>` modal. Set by `RoomTimeline`; non-Room callers
|
||||||
|
// (pin-menu, message search) leave it undefined and stay on the
|
||||||
|
// legacy modal until they're migrated.
|
||||||
|
eventId?: string;
|
||||||
};
|
};
|
||||||
export function RenderMessageContent({
|
export function RenderMessageContent({
|
||||||
displayName,
|
displayName,
|
||||||
|
|
@ -78,6 +84,7 @@ export function RenderMessageContent({
|
||||||
htmlReactParserOptions,
|
htmlReactParserOptions,
|
||||||
linkifyOpts,
|
linkifyOpts,
|
||||||
outlineAttachment,
|
outlineAttachment,
|
||||||
|
eventId,
|
||||||
}: RenderMessageContentProps) {
|
}: RenderMessageContentProps) {
|
||||||
const streamMedia = useStreamMediaContext();
|
const streamMedia = useStreamMediaContext();
|
||||||
const renderUrlsPreview = (urls: string[]) => {
|
const renderUrlsPreview = (urls: string[]) => {
|
||||||
|
|
@ -219,6 +226,7 @@ export function RenderMessageContent({
|
||||||
<ImageContent
|
<ImageContent
|
||||||
{...props}
|
{...props}
|
||||||
autoPlay={mediaAutoLoad}
|
autoPlay={mediaAutoLoad}
|
||||||
|
eventId={eventId}
|
||||||
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
|
||||||
renderViewer={(p) => <ImageViewer {...p} />}
|
renderViewer={(p) => <ImageViewer {...p} />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -258,6 +266,7 @@ export function RenderMessageContent({
|
||||||
body={body}
|
body={body}
|
||||||
info={info}
|
info={info}
|
||||||
{...props}
|
{...props}
|
||||||
|
eventId={eventId}
|
||||||
renderThumbnail={
|
renderThumbnail={
|
||||||
mediaAutoLoad
|
mediaAutoLoad
|
||||||
? () => (
|
? () => (
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,8 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { ModalWide } from '../../../styles/Modal.css';
|
import { ModalWide } from '../../../styles/Modal.css';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
||||||
|
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
||||||
|
|
||||||
type RenderViewerProps = {
|
type RenderViewerProps = {
|
||||||
src: string;
|
src: string;
|
||||||
|
|
@ -44,7 +46,17 @@ type RenderImageProps = {
|
||||||
onLoad: () => void;
|
onLoad: () => void;
|
||||||
onError: () => void;
|
onError: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => void;
|
||||||
tabIndex: number;
|
tabIndex: number;
|
||||||
|
// `role="button"` so assistive tech announces the clickable
|
||||||
|
// image as a button rather than a plain image. Paired with
|
||||||
|
// `aria-label` and an Enter/Space `onKeyDown` to make the
|
||||||
|
// affordance keyboard-activatable per WAI-ARIA. Element-Web
|
||||||
|
// wraps in `<AccessibleButton>` — we keep the bare `<img>` to
|
||||||
|
// avoid relayout, which works because folds' `Image` is
|
||||||
|
// `as<'img'>` and just spreads these props onto the DOM node.
|
||||||
|
role: 'button';
|
||||||
|
'aria-label': string;
|
||||||
};
|
};
|
||||||
export type ImageContentProps = {
|
export type ImageContentProps = {
|
||||||
body: string;
|
body: string;
|
||||||
|
|
@ -55,6 +67,13 @@ export type ImageContentProps = {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
|
// When provided AND the `MediaViewerHostContext` is non-null,
|
||||||
|
// clicking the thumbnail opens the atom-driven horseshoe viewer
|
||||||
|
// (mobile bottom-up sheet / desktop right pane) instead of the
|
||||||
|
// legacy full-screen `<Overlay>` viewer. Non-Room surfaces
|
||||||
|
// (pin-menu, message search) leave the host context as `null` and
|
||||||
|
// therefore keep the legacy modal even if they pass `eventId`.
|
||||||
|
eventId?: string;
|
||||||
renderViewer: (props: RenderViewerProps) => ReactNode;
|
renderViewer: (props: RenderViewerProps) => ReactNode;
|
||||||
renderImage: (props: RenderImageProps) => ReactNode;
|
renderImage: (props: RenderImageProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
@ -70,6 +89,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
autoPlay,
|
autoPlay,
|
||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
|
eventId,
|
||||||
renderViewer,
|
renderViewer,
|
||||||
renderImage,
|
renderImage,
|
||||||
...props
|
...props
|
||||||
|
|
@ -79,12 +99,37 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const host = useMediaViewerHost();
|
||||||
|
const openMediaViewer = useOpenMediaViewer();
|
||||||
|
const useAtomViewer = !!(host && eventId);
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
const [viewer, setViewer] = useState(false);
|
const [viewer, setViewer] = useState(false);
|
||||||
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
const [blurred, setBlurred] = useState(markedAsSpoiler ?? false);
|
||||||
|
|
||||||
|
const handleOpen = () => {
|
||||||
|
if (useAtomViewer && host && eventId) {
|
||||||
|
// The viewer body re-resolves + decrypts the media itself,
|
||||||
|
// owning the blob-URL lifecycle so it can revoke on close.
|
||||||
|
// We deliberately don't pass `srcState.data` here even when
|
||||||
|
// it's available — pinning a blob URL into the atom would
|
||||||
|
// leak it (the atom outlives the timeline thumbnail).
|
||||||
|
openMediaViewer({
|
||||||
|
roomId: host.roomId,
|
||||||
|
eventId,
|
||||||
|
kind: 'image',
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
info,
|
||||||
|
encInfo,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setViewer(true);
|
||||||
|
};
|
||||||
|
|
||||||
const [srcState, loadSrc] = useAsyncCallback(
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
useCallback(async () => {
|
useCallback(async () => {
|
||||||
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
|
||||||
|
|
@ -118,7 +163,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||||
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
|
@ -168,15 +213,28 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box
|
||||||
|
className={classNames(
|
||||||
|
css.AbsoluteContainer,
|
||||||
|
blurred ? css.Blur : css.ImageClickable
|
||||||
|
)}
|
||||||
|
>
|
||||||
{renderImage({
|
{renderImage({
|
||||||
alt: body,
|
alt: body,
|
||||||
title: body,
|
title: body,
|
||||||
src: srcState.data,
|
src: srcState.data,
|
||||||
onLoad: handleLoad,
|
onLoad: handleLoad,
|
||||||
onError: handleError,
|
onError: handleError,
|
||||||
onClick: () => setViewer(true),
|
onClick: handleOpen,
|
||||||
|
onKeyDown: (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleOpen();
|
||||||
|
}
|
||||||
|
},
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
|
role: 'button',
|
||||||
|
'aria-label': body || 'Open media',
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ import {
|
||||||
} from '../../../utils/matrix';
|
} from '../../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { validBlurHash } from '../../../utils/blurHash';
|
import { validBlurHash } from '../../../utils/blurHash';
|
||||||
|
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
|
||||||
|
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
|
||||||
|
|
||||||
type RenderVideoProps = {
|
type RenderVideoProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -50,6 +52,14 @@ type VideoContentProps = {
|
||||||
autoPlay?: boolean;
|
autoPlay?: boolean;
|
||||||
markedAsSpoiler?: boolean;
|
markedAsSpoiler?: boolean;
|
||||||
spoilerReason?: string;
|
spoilerReason?: string;
|
||||||
|
// When provided AND `MediaViewerHostContext` is non-null, tapping
|
||||||
|
// the thumbnail opens the atom-driven horseshoe viewer for video
|
||||||
|
// playback instead of loading + playing inline (which hands off to
|
||||||
|
// the browser's native video-element fullscreen when the user hits
|
||||||
|
// the controls' expand button — that's why the user used to see
|
||||||
|
// Chrome's default video viewer). Non-Room surfaces leave the
|
||||||
|
// host context as `null` and keep the inline player.
|
||||||
|
eventId?: string;
|
||||||
renderThumbnail?: () => ReactNode;
|
renderThumbnail?: () => ReactNode;
|
||||||
renderVideo: (props: RenderVideoProps) => ReactNode;
|
renderVideo: (props: RenderVideoProps) => ReactNode;
|
||||||
};
|
};
|
||||||
|
|
@ -65,6 +75,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
autoPlay,
|
autoPlay,
|
||||||
markedAsSpoiler,
|
markedAsSpoiler,
|
||||||
spoilerReason,
|
spoilerReason,
|
||||||
|
eventId,
|
||||||
renderThumbnail,
|
renderThumbnail,
|
||||||
renderVideo,
|
renderVideo,
|
||||||
...props
|
...props
|
||||||
|
|
@ -74,6 +85,9 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
const blurHash = validBlurHash(info.thumbnail_info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
|
||||||
|
const host = useMediaViewerHost();
|
||||||
|
const openMediaViewer = useOpenMediaViewer();
|
||||||
|
const useAtomViewer = !!(host && eventId);
|
||||||
|
|
||||||
const [load, setLoad] = useState(false);
|
const [load, setLoad] = useState(false);
|
||||||
const [error, setError] = useState(false);
|
const [error, setError] = useState(false);
|
||||||
|
|
@ -106,8 +120,29 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip inline preload in atom-viewer mode — the user gets the
|
||||||
|
// viewer's own resolve path on tap; preloading every visible
|
||||||
|
// video in the timeline would burn bandwidth and decrypt CPU
|
||||||
|
// for videos the user never opens.
|
||||||
|
if (useAtomViewer) return;
|
||||||
if (autoPlay) loadSrc();
|
if (autoPlay) loadSrc();
|
||||||
}, [autoPlay, loadSrc]);
|
}, [autoPlay, loadSrc, useAtomViewer]);
|
||||||
|
|
||||||
|
const openAtomViewer = useCallback(() => {
|
||||||
|
if (!host || !eventId) return;
|
||||||
|
// No `resolvedSrc` — viewer body owns blob-URL lifecycle; see
|
||||||
|
// the rationale in `ImageContent.handleOpen`.
|
||||||
|
openMediaViewer({
|
||||||
|
roomId: host.roomId,
|
||||||
|
eventId,
|
||||||
|
kind: 'video',
|
||||||
|
url,
|
||||||
|
body,
|
||||||
|
info,
|
||||||
|
encInfo,
|
||||||
|
mimeType,
|
||||||
|
});
|
||||||
|
}, [host, eventId, openMediaViewer, url, body, info, encInfo, mimeType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
|
||||||
|
|
@ -129,7 +164,21 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
{renderThumbnail()}
|
{renderThumbnail()}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
{useAtomViewer && !blurred && (
|
||||||
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
|
<Button
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={openAtomViewer}
|
||||||
|
before={<Icon size="Inherit" src={Icons.Play} filled />}
|
||||||
|
>
|
||||||
|
<Text size="B300">Watch</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && (
|
||||||
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
<Box className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center">
|
||||||
<Button
|
<Button
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
|
|
@ -143,7 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{srcState.status === AsyncStatus.Success && (
|
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
|
||||||
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
|
||||||
{renderVideo({
|
{renderVideo({
|
||||||
title: body,
|
title: body,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,36 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config } from 'folds';
|
import { DefaultReset, config } from 'folds';
|
||||||
|
|
||||||
|
// Click affordance for the timeline image thumbnail. Without this
|
||||||
|
// the `<img>` looked decorative on web (the default cursor stays
|
||||||
|
// as `default`) even though it's clickable to open the media
|
||||||
|
// viewer. The subtle 0.92 brightness on hover doubles as the
|
||||||
|
// "this is interactive" signal — same idiom as how the rest of
|
||||||
|
// the app's clickable surfaces shift tone on hover.
|
||||||
|
//
|
||||||
|
// `will-change: filter` hints the compositor so the brightness
|
||||||
|
// transition runs on the GPU instead of repainting on the CPU —
|
||||||
|
// matters for large hi-DPI thumbnails on slower phones.
|
||||||
|
export const ImageClickable = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'filter 120ms ease',
|
||||||
|
willChange: 'filter',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': { filter: 'brightness(0.92)' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// `:focus-visible` outline on the inner `<img>` (which carries
|
||||||
|
// `tabIndex` + `role="button"`, not the wrapper Box). Lives as a
|
||||||
|
// `globalStyle` because vanilla-extract's `style({...})` only
|
||||||
|
// permits selectors that target the class itself (`&...`) — a
|
||||||
|
// descendant selector like `& img:focus-visible` errors at build
|
||||||
|
// time. `globalStyle` is the documented escape valve for that.
|
||||||
|
globalStyle(`${ImageClickable} img:focus-visible`, {
|
||||||
|
outline: `2px solid currentColor`,
|
||||||
|
outlineOffset: '-2px',
|
||||||
|
});
|
||||||
|
|
||||||
export const RelativeBase = style([
|
export const RelativeBase = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
143
src/app/features/room/MediaViewerBody.css.ts
Normal file
143
src/app/features/room/MediaViewerBody.css.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
// Root layout — header strip → image stage → action row. `position:
|
||||||
|
// relative` so the prev/next chevrons can absolute-position over the
|
||||||
|
// stage on desktop.
|
||||||
|
export const root = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// `PageHeader` (folds `Header size="600"`) provides the strip
|
||||||
|
// height; we only override the left gutter so the title sits at
|
||||||
|
// the same x-offset as the profile side pane's title — keeps both
|
||||||
|
// right-pane variants visually consistent.
|
||||||
|
export const header = style({
|
||||||
|
paddingLeft: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const title = style({
|
||||||
|
minWidth: 0,
|
||||||
|
flex: '0 1 auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Vertical hairline between the action cluster and the close
|
||||||
|
// button — visually separates "do something with this image" from
|
||||||
|
// "close the viewer", which are semantically different actions.
|
||||||
|
// Rendered for image kind only (no zoom controls for video → only
|
||||||
|
// download + close, which doesn't need a separator).
|
||||||
|
export const headerSeparator = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
width: '1px',
|
||||||
|
height: toRem(20),
|
||||||
|
margin: `0 ${config.space.S100}`,
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image stage — fills remaining vertical space, centres the image
|
||||||
|
// with object-fit-style logic on the wrapper. `overflow: hidden` so
|
||||||
|
// a zoomed-in pan can't bleed past the stage. `touch-action: none`
|
||||||
|
// blocks browser-native gestures (pinch-zoom, scroll, refresh-pull)
|
||||||
|
// — we own the touch surface here for swipe + pan.
|
||||||
|
export const stage = style({
|
||||||
|
flex: 1,
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
touchAction: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The actual <img>. `max-*: 100%` keeps it within the stage at
|
||||||
|
// zoom=1; transforms (scale, translate) layer above without
|
||||||
|
// re-flowing. `will-change` hints the compositor for smooth pan /
|
||||||
|
// swipe / zoom animations.
|
||||||
|
export const image = style({
|
||||||
|
maxWidth: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
willChange: 'transform',
|
||||||
|
// Sharp pixel-ratio handling on dense screens — keeps phone
|
||||||
|
// photos crisp at zoom=1, smooths only when zoomed beyond
|
||||||
|
// intrinsic resolution.
|
||||||
|
imageRendering: 'auto',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Side chevrons — visibility logic:
|
||||||
|
// • Coarse pointer (touch devices, mobile): always visible at
|
||||||
|
// 0.7 opacity so users have a tappable nav affordance without
|
||||||
|
// needing the hover state that touch lacks. Swipe still works
|
||||||
|
// but the buttons are the discoverability path.
|
||||||
|
// • Fine pointer (desktop): hidden until the stage is hovered,
|
||||||
|
// fading in to full opacity. Keeps the image surface clean
|
||||||
|
// when the cursor isn't on it.
|
||||||
|
// • Boundary (first/last item): the chevron is dimmed (0.25
|
||||||
|
// opacity, pointer-events disabled) rather than hidden, so the
|
||||||
|
// user gets a visual cue that prev/next isn't available
|
||||||
|
// instead of a missing button.
|
||||||
|
export const chevron = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
borderRadius: '50%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||||
|
color: color.Background.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'opacity 150ms ease, background-color 150ms ease',
|
||||||
|
opacity: 0,
|
||||||
|
'@media': {
|
||||||
|
'(pointer: coarse)': {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
selectors: {
|
||||||
|
[`${stage}:hover &`]: { opacity: 1 },
|
||||||
|
'&:hover': { backgroundColor: 'rgba(0, 0, 0, 0.75)' },
|
||||||
|
'&[aria-disabled="true"]': { opacity: 0.25, pointerEvents: 'none' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chevronLeft = style({
|
||||||
|
left: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const chevronRight = style({
|
||||||
|
right: config.space.S300,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Loading / error state overlay — centred inside the stage.
|
||||||
|
export const stateOverlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Index pill — "3 / 12" in the header. Subtle, matches the title
|
||||||
|
// row tone.
|
||||||
|
export const indexPill = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `${toRem(2)} ${config.space.S200}`,
|
||||||
|
borderRadius: toRem(999),
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
fontSize: toRem(12),
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
971
src/app/features/room/MediaViewerBody.tsx
Normal file
971
src/app/features/room/MediaViewerBody.tsx
Normal file
|
|
@ -0,0 +1,971 @@
|
||||||
|
// Body for the room media viewer — mounted inside both the mobile
|
||||||
|
// `MobileMediaViewerHorseshoe` panel-body and the desktop
|
||||||
|
// `RoomViewMediaSidePanel`. Owns:
|
||||||
|
// • Resolving + decrypting the media URL for the current entry,
|
||||||
|
// including revoke-on-unmount for blob URLs we create.
|
||||||
|
// • Zoom for images (button row + `+`/`-`/`0` keys).
|
||||||
|
// • Mouse-drag pan when zoomed, with bounds clamping so the
|
||||||
|
// image can't be dragged off the stage.
|
||||||
|
// • Multi-media navigation: prev / next via on-screen chevrons,
|
||||||
|
// keyboard arrows, and horizontal swipe (image only — video
|
||||||
|
// keeps its native seekbar drag).
|
||||||
|
// • Programmatic `<video>` play on each entry change.
|
||||||
|
// • Download via `FileSaver`.
|
||||||
|
|
||||||
|
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Box, Icon, IconButton, Icons, Spinner, Text } from 'folds';
|
||||||
|
import { PageHeader } from '../../components/page';
|
||||||
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import FileSaver from 'file-saver';
|
||||||
|
import { MatrixEvent, MatrixEventEvent, MsgType, RoomEvent } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { MediaViewerEntry } from '../../state/mediaViewer';
|
||||||
|
import { useOpenMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
import { useZoom } from '../../hooks/useZoom';
|
||||||
|
import {
|
||||||
|
decryptFile,
|
||||||
|
downloadEncryptedMedia,
|
||||||
|
mxcUrlToHttp,
|
||||||
|
} from '../../utils/matrix';
|
||||||
|
import { FALLBACK_MIMETYPE } from '../../utils/mimeTypes';
|
||||||
|
import {
|
||||||
|
IImageContent,
|
||||||
|
IImageInfo,
|
||||||
|
IThumbnailContent,
|
||||||
|
IVideoContent,
|
||||||
|
IVideoInfo,
|
||||||
|
} from '../../../types/matrix/common';
|
||||||
|
import * as css from './MediaViewerBody.css';
|
||||||
|
|
||||||
|
// Distance past which a horizontal pointer drag commits to prev/next
|
||||||
|
// when zoom = 1 (matches the sheet's vertical close threshold).
|
||||||
|
const SWIPE_COMMIT_PX = 80;
|
||||||
|
// Horizontal must dominate vertical by at least this factor before
|
||||||
|
// the gesture is interpreted as a swipe — keeps accidental diagonal
|
||||||
|
// drags from committing.
|
||||||
|
const SWIPE_DOMINANCE = 1.5;
|
||||||
|
|
||||||
|
// Walk the room's loaded timeline events and build the ordered set
|
||||||
|
// of viewable image + video events. Out-of-window media (older than
|
||||||
|
// the loaded scrollback) is intentionally not surfaced for phase 1;
|
||||||
|
// `step()` resistance-snaps at the boundary.
|
||||||
|
function mediaKindFor(ev: MatrixEvent): 'image' | 'video' | null {
|
||||||
|
if (ev.getType() !== 'm.room.message') return null;
|
||||||
|
if (ev.isRedacted()) return null;
|
||||||
|
const content = ev.getContent() as { msgtype?: string; url?: string; file?: { url?: string } };
|
||||||
|
if (!content) return null;
|
||||||
|
const hasMedia = !!content.url || !!content.file?.url;
|
||||||
|
if (!hasMedia) return null;
|
||||||
|
if (content.msgtype === MsgType.Image) return 'image';
|
||||||
|
if (content.msgtype === MsgType.Video) return 'video';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventToEntry(roomId: string, ev: MatrixEvent): MediaViewerEntry | null {
|
||||||
|
const kind = mediaKindFor(ev);
|
||||||
|
if (!kind) return null;
|
||||||
|
const content = ev.getContent() as (IImageContent | IVideoContent) | undefined;
|
||||||
|
if (!content) return null;
|
||||||
|
const eventId = ev.getId();
|
||||||
|
if (!eventId) return null;
|
||||||
|
const file = content.file;
|
||||||
|
const url = file?.url ?? content.url;
|
||||||
|
if (!url) return null;
|
||||||
|
const encInfo: EncryptedAttachmentInfo | undefined = file
|
||||||
|
? (() => {
|
||||||
|
// Strip the `url` field — `EncryptedAttachmentInfo` doesn't
|
||||||
|
// include it (the wire shape `IEncryptedFile` does).
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const { url: _, ...rest } = file;
|
||||||
|
return rest as EncryptedAttachmentInfo;
|
||||||
|
})()
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
roomId,
|
||||||
|
eventId,
|
||||||
|
kind,
|
||||||
|
url,
|
||||||
|
body: content.filename ?? content.body ?? eventId,
|
||||||
|
info: content.info as ((IImageInfo | IVideoInfo) & IThumbnailContent) | undefined,
|
||||||
|
encInfo,
|
||||||
|
mimeType: content.info?.mimetype,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaViewerBodyProps = {
|
||||||
|
entry: MediaViewerEntry;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaViewerBody({ entry, requestClose }: MediaViewerBodyProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const room = useRoom();
|
||||||
|
const openMediaViewer = useOpenMediaViewer();
|
||||||
|
|
||||||
|
// `useZoom` gives us `zoom` + `setZoom`; `zoomIn`/`zoomOut` from
|
||||||
|
// it use a flat max=5, but we want per-image dynamic ceiling.
|
||||||
|
// Override with custom +/− callbacks that read `maxZoomRef`.
|
||||||
|
const { zoom, setZoom } = useZoom(0.25);
|
||||||
|
|
||||||
|
// Pan is local (not the shared `usePan` hook) because we need to
|
||||||
|
// clamp against the image bounds so the user can't drag a zoomed
|
||||||
|
// image past its edges — `usePan` accumulates raw `movementX/Y`
|
||||||
|
// without bounds, which lets the picture escape off-screen.
|
||||||
|
// Clamp formula: with `transform: translate3d(X, Y) scale(zoom)`,
|
||||||
|
// the translate is applied AFTER scaling, so X/Y are in stage
|
||||||
|
// pixels. Max pan in each axis = (rendered_size * zoom -
|
||||||
|
// stage_size) / 2. Image fits within stage at zoom=1 (object-fit:
|
||||||
|
// contain), so clamp is zero there and pan auto-resets via the
|
||||||
|
// `useEffect` below that watches `zoom`.
|
||||||
|
const stageRef = useRef<HTMLDivElement>(null);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [pan, setPan] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||||
|
const [panCursor, setPanCursor] = useState<'grab' | 'grabbing' | 'initial'>('initial');
|
||||||
|
|
||||||
|
// Mirror `zoom` and `pan` into refs so callbacks installed in
|
||||||
|
// long-lived listeners can read the latest values without
|
||||||
|
// re-binding, and so the inlined anchor-aware zoom math in
|
||||||
|
// pinch / wheel can read current zoom/pan synchronously.
|
||||||
|
const zoomRef = useRef(zoom);
|
||||||
|
zoomRef.current = zoom;
|
||||||
|
const panRef = useRef(pan);
|
||||||
|
panRef.current = pan;
|
||||||
|
|
||||||
|
// Per-image zoom bounds. `minZoom` is fixed at 1.0 — with
|
||||||
|
// `object-fit: contain` the image already fills the stage to fit
|
||||||
|
// at zoom=1, and going smaller just leaves dead space. `maxZoom`
|
||||||
|
// is computed from the natural image size in `onImageLoad` so a
|
||||||
|
// hi-res screenshot can zoom up to 1:1 pixels (~ natural /
|
||||||
|
// displayed) and a small avatar gets a friendlier 2× ceiling.
|
||||||
|
// Falls back to a flat 5× for the brief window before load.
|
||||||
|
const MIN_ZOOM = 1;
|
||||||
|
const ZOOM_CEILING_FALLBACK = 5;
|
||||||
|
const ZOOM_CEILING_HARD_CAP = 8;
|
||||||
|
const [maxZoom, setMaxZoom] = useState(ZOOM_CEILING_FALLBACK);
|
||||||
|
// `maxZoom` mirrored into a ref for the long-lived gesture
|
||||||
|
// listeners installed once with `deps=[]` below (they need the
|
||||||
|
// latest ceiling, not the value at install time).
|
||||||
|
const maxZoomRef = useRef(maxZoom);
|
||||||
|
maxZoomRef.current = maxZoom;
|
||||||
|
|
||||||
|
// Custom zoom +/− that respect the dynamic per-image ceiling
|
||||||
|
// (`useZoom`'s built-in `zoomIn`/`zoomOut` would cap at the
|
||||||
|
// flat default of 5). Step size 0.25 matches the original hook
|
||||||
|
// config.
|
||||||
|
const zoomIn = useCallback(() => {
|
||||||
|
setZoom((z) => Math.min(maxZoomRef.current, z + 0.25));
|
||||||
|
}, [setZoom]);
|
||||||
|
const zoomOut = useCallback(() => {
|
||||||
|
setZoom((z) => Math.max(MIN_ZOOM, z - 0.25));
|
||||||
|
}, [setZoom]);
|
||||||
|
|
||||||
|
const onImageLoad = useCallback(() => {
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!img) return;
|
||||||
|
if (img.naturalWidth === 0 || img.offsetWidth === 0) return;
|
||||||
|
// Ratio to reach 1:1 pixels — how far we'd need to scale the
|
||||||
|
// displayed (object-fit-contained) image to match its native
|
||||||
|
// resolution. For images naturally smaller than the display
|
||||||
|
// (avatars), this is <1; floor to 2 so the user can still
|
||||||
|
// inspect detail.
|
||||||
|
const oneToOne = img.naturalWidth / img.offsetWidth;
|
||||||
|
const computed = Math.max(2, oneToOne);
|
||||||
|
setMaxZoom(Math.min(ZOOM_CEILING_HARD_CAP, computed));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clampPan = useCallback(
|
||||||
|
(raw: { x: number; y: number }, currentZoom: number) => {
|
||||||
|
const stage = stageRef.current;
|
||||||
|
const img = imgRef.current;
|
||||||
|
if (!stage || !img) return raw;
|
||||||
|
const stageRect = stage.getBoundingClientRect();
|
||||||
|
const maxX = Math.max(0, (img.offsetWidth * currentZoom - stageRect.width) / 2);
|
||||||
|
const maxY = Math.max(0, (img.offsetHeight * currentZoom - stageRect.height) / 2);
|
||||||
|
return {
|
||||||
|
x: Math.max(-maxX, Math.min(maxX, raw.x)),
|
||||||
|
y: Math.max(-maxY, Math.min(maxY, raw.y)),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Anchor-aware zoom math (image-local point under anchor stays
|
||||||
|
// under the anchor after zoom change) is inlined in the pinch
|
||||||
|
// and wheel handlers below — they each need to construct the
|
||||||
|
// anchor from their own gesture state, so a shared helper would
|
||||||
|
// just be plumbing.
|
||||||
|
|
||||||
|
// Reset zoom, pan, and max-zoom-ceiling whenever the active
|
||||||
|
// entry changes (swipe / arrow / programmatic). Without
|
||||||
|
// resetting `maxZoom`, navigating from a low-res image (where
|
||||||
|
// `onImageLoad` set a small ceiling) to a high-res one would
|
||||||
|
// briefly use the stale ceiling until the new image's `onLoad`
|
||||||
|
// fires. Gesture state is reset in a separate effect below.
|
||||||
|
useEffect(() => {
|
||||||
|
setZoom(1);
|
||||||
|
setPan({ x: 0, y: 0 });
|
||||||
|
setMaxZoom(ZOOM_CEILING_FALLBACK);
|
||||||
|
}, [entry.eventId, setZoom]);
|
||||||
|
|
||||||
|
// Re-clamp the existing pan against the new bounds when zoom
|
||||||
|
// changes (e.g. zooming out from 3× to 1× should snap a panned
|
||||||
|
// image back to centre since there's nothing to pan into).
|
||||||
|
useEffect(() => {
|
||||||
|
setPan((prev) => {
|
||||||
|
const next = clampPan(prev, zoom);
|
||||||
|
if (next.x === prev.x && next.y === prev.y) return prev;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setPanCursor(zoom === 1 ? 'initial' : 'grab');
|
||||||
|
}, [zoom, clampPan]);
|
||||||
|
|
||||||
|
// Mouse-drag pan. Touch is handled by the stage-listener block
|
||||||
|
// below (which routes touch to swipe when zoom=1, ignores when
|
||||||
|
// zoom > 1 — pinch + touch-pan is a follow-up).
|
||||||
|
const onMouseDown: MouseEventHandler<HTMLImageElement> = useCallback(
|
||||||
|
(evt) => {
|
||||||
|
if (zoomRef.current === 1) return;
|
||||||
|
evt.preventDefault();
|
||||||
|
setPanCursor('grabbing');
|
||||||
|
|
||||||
|
const onMove = (mEvt: MouseEvent) => {
|
||||||
|
mEvt.preventDefault();
|
||||||
|
mEvt.stopPropagation();
|
||||||
|
const raw = {
|
||||||
|
x: panRef.current.x + mEvt.movementX,
|
||||||
|
y: panRef.current.y + mEvt.movementY,
|
||||||
|
};
|
||||||
|
const clamped = clampPan(raw, zoomRef.current);
|
||||||
|
panRef.current = clamped;
|
||||||
|
setPan(clamped);
|
||||||
|
};
|
||||||
|
const onUp = (mEvt: MouseEvent) => {
|
||||||
|
mEvt.preventDefault();
|
||||||
|
setPanCursor('grab');
|
||||||
|
document.removeEventListener('mousemove', onMove);
|
||||||
|
document.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMove);
|
||||||
|
document.addEventListener('mouseup', onUp);
|
||||||
|
},
|
||||||
|
[clampPan]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve + decrypt the media URL. Mirrors `ImageContent`'s
|
||||||
|
// pattern. Blob-URL lifecycle is managed via `lastBlobUrlRef`:
|
||||||
|
// every encrypted entry allocates a fresh blob INSIDE the async
|
||||||
|
// callback, and we revoke the previous one before storing the
|
||||||
|
// new one. This pattern handles two leak paths that a naive
|
||||||
|
// `useEffect(srcState)` cleanup misses:
|
||||||
|
// 1. `useAsyncCallback`'s "Request replaced!" reject path —
|
||||||
|
// a rapidly-replaced loadSrc may have created a blob via
|
||||||
|
// `createObjectURL` before the throw, which never reaches
|
||||||
|
// `srcState` and so is never cleaned up. Here we revoke
|
||||||
|
// whatever was last stamped into the ref before stamping
|
||||||
|
// the new one.
|
||||||
|
// 2. Unmount mid-load — `useEffect` cleanup with a stale
|
||||||
|
// `srcState.data` could revoke a blob the new mount is
|
||||||
|
// still using. With a ref-tracked invariant of "we own
|
||||||
|
// `lastBlobUrlRef.current`", the unmount cleanup is
|
||||||
|
// unambiguous.
|
||||||
|
const lastBlobUrlRef = useRef<string | null>(null);
|
||||||
|
const [srcState, loadSrc] = useAsyncCallback(
|
||||||
|
useCallback(async () => {
|
||||||
|
const mediaUrl = mxcUrlToHttp(mx, entry.url, useAuthentication);
|
||||||
|
if (!mediaUrl) throw new Error('Invalid media URL');
|
||||||
|
if (entry.encInfo) {
|
||||||
|
const fileContent = await downloadEncryptedMedia(mediaUrl, (encBuf) =>
|
||||||
|
decryptFile(encBuf, entry.mimeType ?? FALLBACK_MIMETYPE, entry.encInfo!)
|
||||||
|
);
|
||||||
|
const blob = URL.createObjectURL(fileContent);
|
||||||
|
if (lastBlobUrlRef.current && lastBlobUrlRef.current !== blob) {
|
||||||
|
URL.revokeObjectURL(lastBlobUrlRef.current);
|
||||||
|
}
|
||||||
|
lastBlobUrlRef.current = blob;
|
||||||
|
return blob;
|
||||||
|
}
|
||||||
|
return mediaUrl;
|
||||||
|
}, [mx, entry.url, entry.encInfo, entry.mimeType, useAuthentication])
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSrc();
|
||||||
|
}, [loadSrc]);
|
||||||
|
|
||||||
|
// Revoke any blob URL we still own when the viewer body unmounts
|
||||||
|
// (sheet closed entirely, room change, etc).
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (lastBlobUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(lastBlobUrlRef.current);
|
||||||
|
lastBlobUrlRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build the room's image set. Recomputed on each render — cheap
|
||||||
|
// (timeline events are already in memory). Not subscribed to new
|
||||||
|
// events for phase 1; the user navigates among the images that
|
||||||
|
// were visible when they opened the viewer.
|
||||||
|
// Bump a version counter on every live-timeline mutation in this
|
||||||
|
// room so `mediaSet` rebuilds when a new image / video event
|
||||||
|
// arrives mid-viewing — without this the user navigates a frozen
|
||||||
|
// snapshot. Mirror of Element-Web's FilePanel pattern. Redaction
|
||||||
|
// is also subscribed so a remotely-deleted image disappears from
|
||||||
|
// the prev/next chain.
|
||||||
|
//
|
||||||
|
// Per-event `MatrixEventEvent.Decrypted` subscription is deliberately
|
||||||
|
// skipped (cost of attaching N listeners). Late-arriving decrypts
|
||||||
|
// of pre-existing events are an accepted phase-1 limitation —
|
||||||
|
// documented in the comment on `mediaKindFor`.
|
||||||
|
const [timelineVersion, setTimelineVersion] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const bump = () => setTimelineVersion((v) => v + 1);
|
||||||
|
// Filter Timeline events to media kinds so the mediaSet
|
||||||
|
// recompute doesn't fire on every reaction / edit / receipt /
|
||||||
|
// sync rollback (busy rooms produce dozens per second). Each
|
||||||
|
// recompute does an O(n) walk of `getLiveTimeline().getEvents()`.
|
||||||
|
const onTimeline = (ev: MatrixEvent) => {
|
||||||
|
if (mediaKindFor(ev) !== null) bump();
|
||||||
|
};
|
||||||
|
// Encrypted rooms (Vojo's primary surface): events arrive as
|
||||||
|
// `m.room.encrypted` and decrypt asynchronously after the
|
||||||
|
// Timeline event has fired — `mediaKindFor` returns null at
|
||||||
|
// that moment, so `onTimeline` above doesn't bump. When the
|
||||||
|
// event decrypts, `MatrixEventEvent.Decrypted` fires on the
|
||||||
|
// event itself. We attach via the MatrixClient so we catch
|
||||||
|
// every decrypt without subscribing to N per-event listeners,
|
||||||
|
// and filter to this room. Mirror of Element-Web's `FilePanel`
|
||||||
|
// pattern.
|
||||||
|
const onDecrypted = (ev: MatrixEvent) => {
|
||||||
|
if (ev.getRoomId() !== room.roomId) return;
|
||||||
|
if (mediaKindFor(ev) !== null) bump();
|
||||||
|
};
|
||||||
|
// Redactions: bump unconditionally — the redacted event may
|
||||||
|
// have been a media we're currently navigating, and we can't
|
||||||
|
// re-derive its kind once redacted.
|
||||||
|
room.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
room.on(RoomEvent.Redaction, bump);
|
||||||
|
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
return () => {
|
||||||
|
room.removeListener(RoomEvent.Timeline, onTimeline);
|
||||||
|
room.removeListener(RoomEvent.Redaction, bump);
|
||||||
|
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
|
||||||
|
};
|
||||||
|
}, [mx, room]);
|
||||||
|
|
||||||
|
const mediaSet = useMemo(() => {
|
||||||
|
const events = room.getLiveTimeline().getEvents();
|
||||||
|
const entries: MediaViewerEntry[] = [];
|
||||||
|
events.forEach((ev) => {
|
||||||
|
const e = eventToEntry(room.roomId, ev);
|
||||||
|
if (e) entries.push(e);
|
||||||
|
});
|
||||||
|
return entries;
|
||||||
|
// `timelineVersion` is the actual refresh trigger; `entry.eventId`
|
||||||
|
// is intentionally NOT a dep — stepping doesn't change the set.
|
||||||
|
}, [room, timelineVersion]);
|
||||||
|
|
||||||
|
const currentIndex = useMemo(
|
||||||
|
() => mediaSet.findIndex((e) => e.eventId === entry.eventId),
|
||||||
|
[mediaSet, entry.eventId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const step = useCallback(
|
||||||
|
(delta: -1 | 1) => {
|
||||||
|
if (currentIndex < 0) return;
|
||||||
|
const target = currentIndex + delta;
|
||||||
|
if (target < 0 || target >= mediaSet.length) return;
|
||||||
|
const next = mediaSet[target];
|
||||||
|
if (!next) return;
|
||||||
|
openMediaViewer(next);
|
||||||
|
},
|
||||||
|
[currentIndex, mediaSet, openMediaViewer]
|
||||||
|
);
|
||||||
|
|
||||||
|
const canPrev = currentIndex > 0;
|
||||||
|
const canNext = currentIndex >= 0 && currentIndex < mediaSet.length - 1;
|
||||||
|
|
||||||
|
// Keyboard — ArrowLeft / Right step, +/- / 0 manage zoom. Esc is
|
||||||
|
// handled by the parent shell (focus-trap on desktop, explicit
|
||||||
|
// keydown listener on mobile).
|
||||||
|
useEffect(() => {
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented) return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
e.preventDefault();
|
||||||
|
step(-1);
|
||||||
|
} else if (e.key === 'ArrowRight') {
|
||||||
|
e.preventDefault();
|
||||||
|
step(1);
|
||||||
|
} else if (e.key === '+' || e.key === '=') {
|
||||||
|
e.preventDefault();
|
||||||
|
zoomIn();
|
||||||
|
} else if (e.key === '-' || e.key === '_') {
|
||||||
|
e.preventDefault();
|
||||||
|
zoomOut();
|
||||||
|
} else if (e.key === '0') {
|
||||||
|
e.preventDefault();
|
||||||
|
setZoom(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [step, zoomIn, zoomOut, setZoom]);
|
||||||
|
|
||||||
|
// Multi-touch gesture state on the image stage. Three modes:
|
||||||
|
// • zoom=1, 1 finger → horizontal-swipe to prev/next image.
|
||||||
|
// • zoom>1, 1 finger → touch pan (clamped to image bounds; same
|
||||||
|
// formula as the mouse pan path).
|
||||||
|
// • 2 fingers → pinch zoom (distance ratio drives zoom; pan
|
||||||
|
// resets to 0 so the image stays centered while pinching).
|
||||||
|
// Listeners are installed ONCE and read the latest closure values
|
||||||
|
// via refs — without that, a zoom-button press would re-bind every
|
||||||
|
// listener mid-gesture. PointerEvents only (no TouchEvents) — on
|
||||||
|
// Chromium both fire for touch, which double-fires every handler.
|
||||||
|
// The MDN reference pattern that drove this implementation:
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
|
||||||
|
const [swipeOffset, setSwipeOffset] = useState(0);
|
||||||
|
|
||||||
|
const swipeStateRef = useRef({
|
||||||
|
active: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
lastDx: 0,
|
||||||
|
claimed: 'none' as 'none' | 'swipe' | 'reject',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Active touch pointers keyed by `pointerId`. Map (not array)
|
||||||
|
// because PointerEvents can arrive interleaved across multiple
|
||||||
|
// pointers and we need to update the per-id entry on each move.
|
||||||
|
const pointerCacheRef = useRef<Map<number, { x: number; y: number }>>(new Map());
|
||||||
|
|
||||||
|
// Pinch state. `null` when not pinching; set on the 2nd pointer
|
||||||
|
// down. `initialDistance` is the finger spread at pinch start;
|
||||||
|
// `initialZoom` and `initialPan` snapshot the state we'll scale
|
||||||
|
// from. `anchorImageX/Y` is the image-local point under the
|
||||||
|
// pinch midpoint at the start — held constant under the user's
|
||||||
|
// fingers as zoom changes (anchor-aware pinch, mirrors what
|
||||||
|
// Apple Photos / Google Photos / Element-Web's wheel-zoom do).
|
||||||
|
const pinchStateRef = useRef<{
|
||||||
|
initialDistance: number;
|
||||||
|
initialZoom: number;
|
||||||
|
initialPan: { x: number; y: number };
|
||||||
|
anchorImageX: number;
|
||||||
|
anchorImageY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Touch pan state. `null` when not panning. Stores the pan offset
|
||||||
|
// at the start of the gesture so we can compute the delta off the
|
||||||
|
// current pointer position (matches Element-Web's
|
||||||
|
// absolute-delta-from-anchor approach, more reliable than
|
||||||
|
// accumulating `movementX/Y`).
|
||||||
|
const touchPanStateRef = useRef<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
panAtStartX: number;
|
||||||
|
panAtStartY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Refs mirror the latest values for the always-installed listeners
|
||||||
|
// below. Without these, the listeners would close over the values
|
||||||
|
// from the render that installed them.
|
||||||
|
const canPrevRef = useRef(canPrev);
|
||||||
|
canPrevRef.current = canPrev;
|
||||||
|
const canNextRef = useRef(canNext);
|
||||||
|
canNextRef.current = canNext;
|
||||||
|
const stepRef = useRef(step);
|
||||||
|
stepRef.current = step;
|
||||||
|
const entryKindRef = useRef(entry.kind);
|
||||||
|
entryKindRef.current = entry.kind;
|
||||||
|
// `zoomRef` + `panRef` are already mirrored in the pan section
|
||||||
|
// above; reusing them here keeps the source of truth singular.
|
||||||
|
const clampPanRef = useRef(clampPan);
|
||||||
|
clampPanRef.current = clampPan;
|
||||||
|
const setZoomRef = useRef(setZoom);
|
||||||
|
setZoomRef.current = setZoom;
|
||||||
|
// `maxZoomRef` + `zoomIn` / `zoomOut` are declared higher up
|
||||||
|
// (right after the `maxZoom` state) because the keyboard
|
||||||
|
// useEffect above also depends on them.
|
||||||
|
|
||||||
|
// Reset gesture state on entry change. A finger that lifted
|
||||||
|
// off-stage (no `pointerup` delivered) leaves residue in
|
||||||
|
// `pointerCacheRef`, so without this the next entry's first
|
||||||
|
// `pointerdown` sees `count === 2` from nothing and starts a
|
||||||
|
// fake pinch on a stale midpoint. Similar concern for swipe /
|
||||||
|
// touch-pan state. `setPointerCapture` (added in the listeners
|
||||||
|
// below) should normally guarantee a pointerup delivery; this
|
||||||
|
// reset is the defence-in-depth.
|
||||||
|
useEffect(() => {
|
||||||
|
pointerCacheRef.current.clear();
|
||||||
|
pinchStateRef.current = null;
|
||||||
|
touchPanStateRef.current = null;
|
||||||
|
swipeStateRef.current.active = false;
|
||||||
|
swipeStateRef.current.claimed = 'none';
|
||||||
|
setSwipeOffset(0);
|
||||||
|
}, [entry.eventId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stageEl = stageRef.current;
|
||||||
|
if (!stageEl) return undefined;
|
||||||
|
|
||||||
|
const distanceBetween = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
||||||
|
Math.hypot(a.x - b.x, a.y - b.y);
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
// Mouse drag at zoom>1 is handled by the image's `onMouseDown`
|
||||||
|
// → pan; mouse drag at zoom=1 has no useful gesture. Skip
|
||||||
|
// mouse here for everything.
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
if (entryKindRef.current !== 'image') return;
|
||||||
|
|
||||||
|
// Capture the pointer so subsequent `pointermove`/`pointerup`
|
||||||
|
// events are dispatched to the stage even when the finger
|
||||||
|
// leaves the stage rect (e.g. user pans onto the header).
|
||||||
|
// Without this, the gesture can be stranded mid-flight with
|
||||||
|
// a finger off-stage, leaving `pointerCacheRef` populated and
|
||||||
|
// breaking the next gesture. `try/catch` because some
|
||||||
|
// browsers reject capture on non-trusted events under
|
||||||
|
// automation harnesses.
|
||||||
|
try {
|
||||||
|
stageEl.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
// ignore — capture is best-effort
|
||||||
|
}
|
||||||
|
|
||||||
|
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
const count = pointerCacheRef.current.size;
|
||||||
|
|
||||||
|
if (count === 2) {
|
||||||
|
// 2nd finger arrived — switch to pinch mode. Cancel any
|
||||||
|
// in-flight single-finger gesture (swipe / touch pan) so
|
||||||
|
// it doesn't leak state into the pinch.
|
||||||
|
const pts = Array.from(pointerCacheRef.current.values());
|
||||||
|
// Pinch midpoint → stage-center-relative anchor → derive
|
||||||
|
// the image-local point that's under the midpoint right
|
||||||
|
// now. Holding this point under the moving midpoint is
|
||||||
|
// what makes the zoom feel anchored.
|
||||||
|
const midClientX = (pts[0].x + pts[1].x) / 2;
|
||||||
|
const midClientY = (pts[0].y + pts[1].y) / 2;
|
||||||
|
const stageRect = stageEl.getBoundingClientRect();
|
||||||
|
const anchorX = midClientX - (stageRect.left + stageRect.width / 2);
|
||||||
|
const anchorY = midClientY - (stageRect.top + stageRect.height / 2);
|
||||||
|
const z0 = zoomRef.current;
|
||||||
|
const p0 = panRef.current;
|
||||||
|
pinchStateRef.current = {
|
||||||
|
initialDistance: distanceBetween(pts[0], pts[1]),
|
||||||
|
initialZoom: z0,
|
||||||
|
initialPan: { x: p0.x, y: p0.y },
|
||||||
|
anchorImageX: (anchorX - p0.x) / z0,
|
||||||
|
anchorImageY: (anchorY - p0.y) / z0,
|
||||||
|
};
|
||||||
|
if (swipeStateRef.current.active) {
|
||||||
|
swipeStateRef.current.active = false;
|
||||||
|
setSwipeOffset(0);
|
||||||
|
}
|
||||||
|
touchPanStateRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
if (zoomRef.current === 1) {
|
||||||
|
// 1 finger + zoom=1 → swipe
|
||||||
|
const s = swipeStateRef.current;
|
||||||
|
s.active = true;
|
||||||
|
s.startX = e.clientX;
|
||||||
|
s.startY = e.clientY;
|
||||||
|
s.lastDx = 0;
|
||||||
|
s.claimed = 'none';
|
||||||
|
setSwipeOffset(0);
|
||||||
|
} else {
|
||||||
|
// 1 finger + zoom>1 → touch pan
|
||||||
|
touchPanStateRef.current = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
panAtStartX: panRef.current.x,
|
||||||
|
panAtStartY: panRef.current.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
const cached = pointerCacheRef.current.get(e.pointerId);
|
||||||
|
if (!cached) return;
|
||||||
|
pointerCacheRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||||
|
|
||||||
|
// Pinch always wins when 2 fingers are down.
|
||||||
|
if (pinchStateRef.current && pointerCacheRef.current.size === 2) {
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
const ps = pinchStateRef.current;
|
||||||
|
const pts = Array.from(pointerCacheRef.current.values());
|
||||||
|
const dist = distanceBetween(pts[0], pts[1]);
|
||||||
|
const ratio = dist / ps.initialDistance;
|
||||||
|
const nextZoom = Math.max(
|
||||||
|
MIN_ZOOM,
|
||||||
|
Math.min(maxZoomRef.current, ps.initialZoom * ratio)
|
||||||
|
);
|
||||||
|
// Anchor-aware: hold `anchorImage*` (image-local point
|
||||||
|
// captured at pinch start) under the moving midpoint.
|
||||||
|
const midClientX = (pts[0].x + pts[1].x) / 2;
|
||||||
|
const midClientY = (pts[0].y + pts[1].y) / 2;
|
||||||
|
const stageRect = stageEl.getBoundingClientRect();
|
||||||
|
const anchorStageX = midClientX - (stageRect.left + stageRect.width / 2);
|
||||||
|
const anchorStageY = midClientY - (stageRect.top + stageRect.height / 2);
|
||||||
|
const rawPan = {
|
||||||
|
x: anchorStageX - ps.anchorImageX * nextZoom,
|
||||||
|
y: anchorStageY - ps.anchorImageY * nextZoom,
|
||||||
|
};
|
||||||
|
const clamped = clampPanRef.current(rawPan, nextZoom);
|
||||||
|
panRef.current = clamped;
|
||||||
|
setPan(clamped);
|
||||||
|
setZoomRef.current(nextZoom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single-finger paths run only if pinch isn't active.
|
||||||
|
if (pinchStateRef.current) return;
|
||||||
|
|
||||||
|
// Swipe path (zoom=1).
|
||||||
|
const s = swipeStateRef.current;
|
||||||
|
if (s.active) {
|
||||||
|
const dx = e.clientX - s.startX;
|
||||||
|
const dy = e.clientY - s.startY;
|
||||||
|
s.lastDx = dx;
|
||||||
|
if (s.claimed === 'none') {
|
||||||
|
if (Math.abs(dx) > 8 || Math.abs(dy) > 8) {
|
||||||
|
if (Math.abs(dx) > Math.abs(dy) * SWIPE_DOMINANCE) s.claimed = 'swipe';
|
||||||
|
else s.claimed = 'reject';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s.claimed !== 'swipe') return;
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
const blocked =
|
||||||
|
(dx > 0 && !canPrevRef.current) || (dx < 0 && !canNextRef.current);
|
||||||
|
setSwipeOffset(blocked ? dx / 3 : dx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Touch-pan path (zoom>1).
|
||||||
|
const tp = touchPanStateRef.current;
|
||||||
|
if (tp) {
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
const raw = {
|
||||||
|
x: tp.panAtStartX + (e.clientX - tp.startX),
|
||||||
|
y: tp.panAtStartY + (e.clientY - tp.startY),
|
||||||
|
};
|
||||||
|
const clamped = clampPanRef.current(raw, zoomRef.current);
|
||||||
|
panRef.current = clamped;
|
||||||
|
setPan(clamped);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerEnd = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'mouse') return;
|
||||||
|
// Release the pointer capture taken on `pointerdown`. Browsers
|
||||||
|
// auto-release on `pointerup` / `pointercancel`, but
|
||||||
|
// explicitly calling it on a non-captured pointer is a no-op
|
||||||
|
// and the explicit release pairs the API call for clarity.
|
||||||
|
try {
|
||||||
|
if (stageEl.hasPointerCapture(e.pointerId)) {
|
||||||
|
stageEl.releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
pointerCacheRef.current.delete(e.pointerId);
|
||||||
|
const count = pointerCacheRef.current.size;
|
||||||
|
|
||||||
|
if (pinchStateRef.current && count < 2) {
|
||||||
|
// Pinch ended. Reset pinch state but DON'T immediately
|
||||||
|
// switch back to swipe / pan — the remaining finger (if
|
||||||
|
// any) needs to lift first and the user starts a fresh
|
||||||
|
// gesture cleanly.
|
||||||
|
pinchStateRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
const s = swipeStateRef.current;
|
||||||
|
if (s.active) {
|
||||||
|
s.active = false;
|
||||||
|
if (s.claimed === 'swipe') {
|
||||||
|
if (s.lastDx > SWIPE_COMMIT_PX && canPrevRef.current) stepRef.current(-1);
|
||||||
|
else if (s.lastDx < -SWIPE_COMMIT_PX && canNextRef.current) stepRef.current(1);
|
||||||
|
}
|
||||||
|
setSwipeOffset(0);
|
||||||
|
}
|
||||||
|
touchPanStateRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Desktop wheel zoom — anchored to the cursor (the same point
|
||||||
|
// under the cursor stays under the cursor as zoom changes).
|
||||||
|
// Element-Web's idiom: step factor proportional to `deltaY`,
|
||||||
|
// sign inverted so wheel-up zooms IN. `passive: false` so we
|
||||||
|
// can preventDefault the browser's native page scroll.
|
||||||
|
//
|
||||||
|
// Browsers send wheel events with very different `deltaMode`
|
||||||
|
// values (pixel vs line vs page). We normalize to pixels.
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (entryKindRef.current !== 'image') return;
|
||||||
|
e.preventDefault();
|
||||||
|
const dyPx =
|
||||||
|
e.deltaMode === 1 ? e.deltaY * 16 // line mode → ~16px / line
|
||||||
|
: e.deltaMode === 2 ? e.deltaY * stageEl.clientHeight // page mode
|
||||||
|
: e.deltaY;
|
||||||
|
const factor = Math.exp(-dyPx * 0.0025);
|
||||||
|
const oldZoom = zoomRef.current;
|
||||||
|
const nextZoomRaw = oldZoom * factor;
|
||||||
|
const nextZoom = Math.max(MIN_ZOOM, Math.min(maxZoomRef.current, nextZoomRaw));
|
||||||
|
if (nextZoom === oldZoom) return;
|
||||||
|
// Anchor-aware: hold the image-local point under the cursor
|
||||||
|
// fixed across the zoom change.
|
||||||
|
const stageRect = stageEl.getBoundingClientRect();
|
||||||
|
const anchorX = e.clientX - (stageRect.left + stageRect.width / 2);
|
||||||
|
const anchorY = e.clientY - (stageRect.top + stageRect.height / 2);
|
||||||
|
const p0 = panRef.current;
|
||||||
|
const imageX = (anchorX - p0.x) / oldZoom;
|
||||||
|
const imageY = (anchorY - p0.y) / oldZoom;
|
||||||
|
const rawPan = {
|
||||||
|
x: anchorX - imageX * nextZoom,
|
||||||
|
y: anchorY - imageY * nextZoom,
|
||||||
|
};
|
||||||
|
const clamped = clampPanRef.current(rawPan, nextZoom);
|
||||||
|
panRef.current = clamped;
|
||||||
|
setPan(clamped);
|
||||||
|
setZoomRef.current(nextZoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
stageEl.addEventListener('pointerdown', onPointerDown);
|
||||||
|
stageEl.addEventListener('pointermove', onPointerMove);
|
||||||
|
stageEl.addEventListener('pointerup', onPointerEnd);
|
||||||
|
stageEl.addEventListener('pointercancel', onPointerEnd);
|
||||||
|
stageEl.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stageEl.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
stageEl.removeEventListener('pointermove', onPointerMove);
|
||||||
|
stageEl.removeEventListener('pointerup', onPointerEnd);
|
||||||
|
stageEl.removeEventListener('pointercancel', onPointerEnd);
|
||||||
|
stageEl.removeEventListener('wheel', onWheel);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const effectiveSrc = srcState.status === AsyncStatus.Success ? srcState.data : undefined;
|
||||||
|
const isLoading =
|
||||||
|
!effectiveSrc &&
|
||||||
|
(srcState.status === AsyncStatus.Loading || srcState.status === AsyncStatus.Idle);
|
||||||
|
const isError = !effectiveSrc && srcState.status === AsyncStatus.Error;
|
||||||
|
const isReady = !!effectiveSrc;
|
||||||
|
|
||||||
|
const handleDownload = useCallback(async () => {
|
||||||
|
if (!effectiveSrc) return;
|
||||||
|
FileSaver.saveAs(effectiveSrc, entry.body);
|
||||||
|
}, [effectiveSrc, entry.body]);
|
||||||
|
|
||||||
|
// Video autoplay: the `<video>` element below ships with the
|
||||||
|
// `muted` + `autoPlay` HTML attributes. Muted autoplay is
|
||||||
|
// universally allowed (no gesture-activation needed) across
|
||||||
|
// Chrome / Firefox / WebKit / Android Capacitor / iOS Safari —
|
||||||
|
// see the MDN Autoplay guide. Unmuted autoplay would require a
|
||||||
|
// fresh user-gesture per video, which fails on iOS Safari
|
||||||
|
// after the first step in this viewer. Element-Web takes the
|
||||||
|
// same `muted` stance for the same reason. The user can unmute
|
||||||
|
// via the native `<video>` controls; subsequent videos in the
|
||||||
|
// same viewer session keep their unmuted state inherited
|
||||||
|
// through the element remount (key={entry.eventId} forces a
|
||||||
|
// fresh element, so unmute does NOT persist — accepted UX
|
||||||
|
// trade for "playing always works").
|
||||||
|
|
||||||
|
const transform = isReady
|
||||||
|
? `translate3d(${swipeOffset + pan.x}px, ${pan.y}px, 0) scale(${zoom})`
|
||||||
|
: undefined;
|
||||||
|
const imageTransition = swipeOffset === 0 ? 'transform 200ms cubic-bezier(0.32, 0.72, 0, 1)' : 'none';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={css.root}>
|
||||||
|
<PageHeader
|
||||||
|
// `PageHeader` (= folds `Header size="600"`) so the header's
|
||||||
|
// bottom rule aligns to the pixel with the chat header to the
|
||||||
|
// left across the 12px void gap — same trick the profile
|
||||||
|
// side pane uses. A plain `<div height: 48px>` was visibly
|
||||||
|
// shorter and broke the cross-pane seam.
|
||||||
|
className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Text className={css.title} size="H4" truncate>
|
||||||
|
{entry.body}
|
||||||
|
</Text>
|
||||||
|
{mediaSet.length > 1 && (
|
||||||
|
<span className={css.indexPill} aria-hidden="true">
|
||||||
|
{currentIndex >= 0 ? currentIndex + 1 : '?'} / {mediaSet.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center" gap="100">
|
||||||
|
{entry.kind === 'image' && (
|
||||||
|
<>
|
||||||
|
<IconButton
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={zoomOut}
|
||||||
|
aria-label={t('MediaViewer.zoom_out', 'Zoom out')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Minus} />
|
||||||
|
</IconButton>
|
||||||
|
<Text size="B300" as="span" style={{ minWidth: '3em', textAlign: 'center' }}>
|
||||||
|
{Math.round(zoom * 100)}%
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={zoomIn}
|
||||||
|
aria-label={t('MediaViewer.zoom_in', 'Zoom in')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Plus} />
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={!isReady}
|
||||||
|
aria-label={t('MediaViewer.download', 'Download')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Download} />
|
||||||
|
</IconButton>
|
||||||
|
{entry.kind === 'image' && (
|
||||||
|
<div className={css.headerSeparator} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
variant="Background"
|
||||||
|
fill="None"
|
||||||
|
radii="300"
|
||||||
|
size="300"
|
||||||
|
onClick={requestClose}
|
||||||
|
aria-label={t('Common.close', 'Close')}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<div className={css.stage} ref={stageRef}>
|
||||||
|
{isReady && effectiveSrc && entry.kind === 'image' && (
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={effectiveSrc}
|
||||||
|
alt={entry.body}
|
||||||
|
className={css.image}
|
||||||
|
draggable={false}
|
||||||
|
style={{
|
||||||
|
transform,
|
||||||
|
cursor: panCursor,
|
||||||
|
transition: imageTransition,
|
||||||
|
}}
|
||||||
|
onMouseDown={onMouseDown}
|
||||||
|
onLoad={onImageLoad}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isReady && effectiveSrc && entry.kind === 'video' && (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
key={entry.eventId}
|
||||||
|
src={effectiveSrc}
|
||||||
|
title={entry.body}
|
||||||
|
className={css.image}
|
||||||
|
controls
|
||||||
|
// Muted + autoPlay: see the rationale comment above
|
||||||
|
// — muted autoplay is the only policy-compliant way
|
||||||
|
// to start every video automatically including on iOS
|
||||||
|
// Safari after a swipe / chevron step. User unmutes
|
||||||
|
// via native controls.
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${swipeOffset}px, 0, 0)`,
|
||||||
|
transition: imageTransition,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className={css.stateOverlay}>
|
||||||
|
<Spinner variant="Secondary" size="600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<div className={css.stateOverlay} style={{ pointerEvents: 'auto' }}>
|
||||||
|
<IconButton
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
size="400"
|
||||||
|
onClick={() => loadSrc()}
|
||||||
|
aria-label={t('Common.retry', 'Retry')}
|
||||||
|
>
|
||||||
|
<Icon size="200" src={Icons.Warning} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mediaSet.length > 1 && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(css.chevron, css.chevronLeft)}
|
||||||
|
aria-label={t('MediaViewer.previous', 'Previous')}
|
||||||
|
// `aria-disabled` (not `disabled`) so the CSS dimmed-
|
||||||
|
// state selector still matches; the onClick already
|
||||||
|
// bails internally via `step`'s bounds check.
|
||||||
|
aria-disabled={!canPrev}
|
||||||
|
onClick={() => step(-1)}
|
||||||
|
>
|
||||||
|
<Icon size="200" src={Icons.ChevronLeft} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={classNames(css.chevron, css.chevronRight)}
|
||||||
|
aria-label={t('MediaViewer.next', 'Next')}
|
||||||
|
aria-disabled={!canNext}
|
||||||
|
onClick={() => step(1)}
|
||||||
|
>
|
||||||
|
<Icon size="200" src={Icons.ChevronRight} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
src/app/features/room/MobileMediaViewerHorseshoe.css.ts
Normal file
107
src/app/features/room/MobileMediaViewerHorseshoe.css.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||||
|
|
||||||
|
// Re-exported so the TSX can pick up the constants without crossing
|
||||||
|
// the vanilla-extract / runtime boundary twice.
|
||||||
|
export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
|
||||||
|
export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
|
||||||
|
|
||||||
|
// Outer container — anchor for the two absolutely-positioned panes
|
||||||
|
// (`appBody` and `silhouette`). Same shape as the settings-sheet
|
||||||
|
// container, see that file for the full rationale.
|
||||||
|
export const container = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Holds the wrapped chat column. Stays put — clip-path carves the
|
||||||
|
// bottom edge so virtualized timeline rows aren't re-measured
|
||||||
|
// mid-gesture. `backgroundColor` opaque so the void colour painted
|
||||||
|
// on the container doesn't bleed through any transparent slivers
|
||||||
|
// in the wrapped tree (e.g. between bubble rows).
|
||||||
|
export const appBody = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
willChange: 'clip-path',
|
||||||
|
});
|
||||||
|
|
||||||
|
// The viewer sheet's surface. `Background.Container` (deepest Dawn
|
||||||
|
// tone) so the image floats on a near-black backdrop — closest to
|
||||||
|
// the legacy `<Overlay>` viewer's full-screen dark scrim.
|
||||||
|
export const silhouette = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
willChange: 'height, border-top-left-radius, border-top-right-radius',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top-anchored — handle + viewer body reveal top-down as silhouette
|
||||||
|
// grows. `padding-bottom: env(safe-area-inset-bottom)` keeps the
|
||||||
|
// download / share row clear of the Android nav bar.
|
||||||
|
export const panelContent = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 20px drag-to-close band. The ONLY drag-to-close origin — touches
|
||||||
|
// on the viewer body below this strip drive zoom / pan / swipe and
|
||||||
|
// MUST NOT initiate a close gesture. `touchAction: none` blocks
|
||||||
|
// browser-native scroll on this strip.
|
||||||
|
export const panelHandle = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
height: toRem(20),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'grab',
|
||||||
|
touchAction: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&:active': { cursor: 'grabbing' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Darker than `Background.Container` would make the grabber
|
||||||
|
// disappear — use `SurfaceVariant.Container` so the bar reads
|
||||||
|
// against the dark viewer bg. Mirror of the settings handle's
|
||||||
|
// step-up against `SurfaceVariant.Container` (where it uses the
|
||||||
|
// deeper `Background.Container`).
|
||||||
|
export const panelHandleBar = style({
|
||||||
|
width: toRem(36),
|
||||||
|
height: toRem(4),
|
||||||
|
borderRadius: toRem(4),
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const panelBody = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
419
src/app/features/room/MobileMediaViewerHorseshoe.tsx
Normal file
419
src/app/features/room/MobileMediaViewerHorseshoe.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
// Bottom-up «horseshoe» sheet that wraps the mobile chat column for
|
||||||
|
// the media viewer. Sister of `MobileSettingsHorseshoe` (which wraps
|
||||||
|
// the DM list with the same idiom for the Settings tree) — same
|
||||||
|
// clip-path carve, same VAUL easing, same Strict-Mode-safe entry
|
||||||
|
// animation, same `keepMounted` delayed unmount.
|
||||||
|
//
|
||||||
|
// Differences from the settings sheet:
|
||||||
|
// • Tap-to-open only. No drag-up origin — opening is driven from
|
||||||
|
// `useOpenMediaViewer` called by `ImageContent.onClick`. The
|
||||||
|
// 20px handle band is the ONLY drag-sensitive area; touches on
|
||||||
|
// the viewer body below run zoom / pan / horizontal swipe
|
||||||
|
// instead.
|
||||||
|
// • `RAIL_FRACTION = 3 / 4` (75% of viewport) vs settings's 2/3.
|
||||||
|
// • Silhouette bg = `Background.Container` (deepest Dawn tone) for
|
||||||
|
// a dark image backdrop, vs settings's `SurfaceVariant.Container`.
|
||||||
|
// • Wrapped content is the chat column, not the DM list.
|
||||||
|
// • No `--vojo-safe-top` reset — there's no PageNav inside the
|
||||||
|
// viewer body that would inherit it; the viewer body has its own
|
||||||
|
// padding policy.
|
||||||
|
|
||||||
|
import React, { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
|
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
|
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||||
|
import { MediaViewerBody } from './MediaViewerBody';
|
||||||
|
import * as css from './MobileMediaViewerHorseshoe.css';
|
||||||
|
|
||||||
|
const VAUL_EASING = 'cubic-bezier(0.32, 0.72, 0, 1)';
|
||||||
|
const ANIMATION_MS = 250;
|
||||||
|
// Drag distance past which release commits the close. Matches the
|
||||||
|
// settings sheet's 80px (the only commit gesture there too).
|
||||||
|
const COMMIT_THRESHOLD_PX = 80;
|
||||||
|
// Sheet height as a fraction of the viewport — product spec.
|
||||||
|
// Capped at runtime by the wrapper container's actual height
|
||||||
|
// (see `railHeightPx` calc below) so it can't spill past the
|
||||||
|
// chat header when chrome outside the chat column (e.g.
|
||||||
|
// `HorseshoeContainer.bottomRail` during a call) shrinks the
|
||||||
|
// wrapper.
|
||||||
|
const RAIL_VIEWPORT_FRACTION = 0.8;
|
||||||
|
// Absolute floor for tiny / split-screen viewports — the sheet
|
||||||
|
// becomes useless below this. NB: floor cannot exceed the
|
||||||
|
// container's ceiling (`Math.min` is applied last), so in extreme
|
||||||
|
// squeeze cases (split-screen, oversized call rail) the sheet
|
||||||
|
// silently shrinks below the floor rather than clipping the chat
|
||||||
|
// header.
|
||||||
|
const RAIL_MIN_PX = 200;
|
||||||
|
// Headroom reserved at the TOP of the container for the chat
|
||||||
|
// header (`PageHeader` = folds `Header size="600"`, ~64px) plus
|
||||||
|
// the 12px horseshoe void gap above the silhouette's rounded
|
||||||
|
// TL/TR carves. With this in place the sheet's top edge stops
|
||||||
|
// just below the chat header even when the wrapper container
|
||||||
|
// shrinks (e.g. `HorseshoeContainer.bottomRail` is on during a
|
||||||
|
// call). Previously the headroom was 8px which let the sheet
|
||||||
|
// climb over the header in tight viewports.
|
||||||
|
const CHAT_HEADER_RESERVED_PX = 76;
|
||||||
|
// Same emerge window as the settings sheet so the silhouette's
|
||||||
|
// rounding + void gap finish exactly when the gesture qualifies to
|
||||||
|
// commit.
|
||||||
|
const HORSESHOE_EMERGE_PX = 80;
|
||||||
|
|
||||||
|
// Symmetric cubic in-out — slow start, fast middle, slow finish.
|
||||||
|
// Same curve the settings / profile horseshoes use for their emerge
|
||||||
|
// (rationale at the top of `MobileSettingsHorseshoe.tsx`).
|
||||||
|
const easeInOutCubic = (t: number): number =>
|
||||||
|
t < 0.5 ? 4 * t * t * t : 1 - ((-2 * t + 2) ** 3) / 2;
|
||||||
|
|
||||||
|
type DragState = {
|
||||||
|
inputType: 'touch' | 'pointer';
|
||||||
|
startY: number;
|
||||||
|
deltaY: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MobileMediaViewerHorseshoeProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function MobileMediaViewerHorseshoeImpl({ children }: MobileMediaViewerHorseshoeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entry = useAtomValue(mediaViewerAtom);
|
||||||
|
const closeSheet = useCloseMediaViewer();
|
||||||
|
|
||||||
|
const [drag, setDrag] = useState<DragState | null>(null);
|
||||||
|
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||||
|
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => setViewportHeight(window.innerHeight);
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Measure the wrapper container directly via `ResizeObserver` so
|
||||||
|
// the rail height tracks whatever vertical space is actually
|
||||||
|
// available to the chat column. The viewport-based fraction is
|
||||||
|
// the *intent* (90% of the screen — product spec), but
|
||||||
|
// `HorseshoeContainer.bottomRail` (the call rail) sits OUTSIDE
|
||||||
|
// the chat column inside the same viewport and shrinks our
|
||||||
|
// wrapper when active. Without the container clamp, 90% of the
|
||||||
|
// viewport could exceed the wrapper's height, and the sheet
|
||||||
|
// would render up to / past the chat header which sits inside
|
||||||
|
// the wrapper's appBody.
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [containerHeight, setContainerHeight] = useState(() =>
|
||||||
|
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||||
|
);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return undefined;
|
||||||
|
setContainerHeight(el.clientHeight);
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const cr = entries[0]?.contentRect;
|
||||||
|
if (cr) setContainerHeight(cr.height);
|
||||||
|
});
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Final rail = viewport × fraction, then floor-then-ceiling
|
||||||
|
// clamped. Container (minus chat-header reserve) is the HARD
|
||||||
|
// ceiling: when the wrapper shrinks (call rail / split-screen)
|
||||||
|
// the sheet shrinks below `RAIL_MIN_PX` rather than spilling
|
||||||
|
// over the chat header. Order matters — `Math.min` last makes
|
||||||
|
// container win against floor.
|
||||||
|
const idealRailPx = Math.round(viewportHeight * RAIL_VIEWPORT_FRACTION);
|
||||||
|
const railHeightPx = Math.min(
|
||||||
|
Math.max(0, containerHeight - CHAT_HEADER_RESERVED_PX),
|
||||||
|
Math.max(RAIL_MIN_PX, idealRailPx)
|
||||||
|
);
|
||||||
|
const open = !!entry;
|
||||||
|
|
||||||
|
// Entry-animation gate — see `MobileSettingsHorseshoe` for the
|
||||||
|
// full rationale. `hasEnteredRef` mirror guards the atom-clearing
|
||||||
|
// unmount cleanup against React 18 strict-mode dev rehearsal.
|
||||||
|
const [hasEntered, setHasEntered] = useState(false);
|
||||||
|
const hasEnteredRef = useRef(false);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const id = requestAnimationFrame(() => {
|
||||||
|
hasEnteredRef.current = true;
|
||||||
|
setHasEntered(true);
|
||||||
|
});
|
||||||
|
return () => cancelAnimationFrame(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Keep the viewer body mounted through the 250ms exit slide so
|
||||||
|
// the user sees the image slide down with the sheet instead of
|
||||||
|
// an empty panel collapsing.
|
||||||
|
const [keepMounted, setKeepMounted] = useState(open);
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setKeepMounted(true);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => setKeepMounted(false), ANIMATION_MS);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Persist the last opened entry through the exit animation so the
|
||||||
|
// body keeps rendering the image as it slides down.
|
||||||
|
const lastEntryRef = useRef(entry);
|
||||||
|
if (entry) lastEntryRef.current = entry;
|
||||||
|
|
||||||
|
const baseExpanded = open && hasEntered ? railHeightPx : 0;
|
||||||
|
// Drag is close-only (positive deltaY); clamp negative deltas to
|
||||||
|
// 0 so an upward reversal cancels rather than expanding past the
|
||||||
|
// open height.
|
||||||
|
const expandedPx = drag
|
||||||
|
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||||
|
: baseExpanded;
|
||||||
|
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||||
|
const isDragging = drag !== null;
|
||||||
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
|
const handleRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const dragRef = useRef<DragState | null>(null);
|
||||||
|
dragRef.current = drag;
|
||||||
|
const entryRef = useRef(entry);
|
||||||
|
entryRef.current = entry;
|
||||||
|
const closeSheetRef = useRef(closeSheet);
|
||||||
|
closeSheetRef.current = closeSheet;
|
||||||
|
|
||||||
|
// Clear the atom if the wrapper unmounts while the sheet is open
|
||||||
|
// (route change). Strict-mode rehearsal guard via `hasEnteredRef`
|
||||||
|
// — without it, the cleanup fires before the entry rAF and a
|
||||||
|
// deep-link cold-start would clear the atom mid-rehearsal.
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (!hasEnteredRef.current) return;
|
||||||
|
if (entryRef.current) closeSheetRef.current();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Hardware Escape (also dispatched by the global Android-back
|
||||||
|
// handler via the portal-marker pattern below). Skip inputs /
|
||||||
|
// contenteditable so typing inside a future caption field doesn't
|
||||||
|
// dismiss the sheet.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (
|
||||||
|
target &&
|
||||||
|
(target.tagName === 'INPUT' ||
|
||||||
|
target.tagName === 'TEXTAREA' ||
|
||||||
|
target.isContentEditable)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closeSheetRef.current();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', onKeyDown);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Drag-to-close only — handle band at the top of the silhouette.
|
||||||
|
// No document-level open-drag listener; opening is tap-only via
|
||||||
|
// `useOpenMediaViewer`.
|
||||||
|
//
|
||||||
|
// CLAMP, not early-return — see `MobileSettingsHorseshoe` for
|
||||||
|
// the bug-fix rationale. Reversing the gesture must drag deltaY
|
||||||
|
// back toward 0, not leave the stale value.
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEl = handleRef.current;
|
||||||
|
if (!handleEl) return undefined;
|
||||||
|
|
||||||
|
const applyMove = (clientY: number, e: TouchEvent | PointerEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
const rawDelta = clientY - d.startY;
|
||||||
|
const nextDelta = Math.max(0, rawDelta);
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
setDrag({ ...d, deltaY: nextDelta });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyEnd = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d) return;
|
||||||
|
if (d.deltaY > COMMIT_THRESHOLD_PX) {
|
||||||
|
closeSheetRef.current();
|
||||||
|
}
|
||||||
|
setDrag(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandleTouchStart = (e: TouchEvent) => {
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (!entryRef.current) return;
|
||||||
|
const touch = e.touches[0];
|
||||||
|
setDrag({ inputType: 'touch', startY: touch.clientY, deltaY: 0 });
|
||||||
|
};
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'touch') return;
|
||||||
|
applyMove(e.touches[0].clientY, e);
|
||||||
|
};
|
||||||
|
const onTouchEnd = () => {
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'touch') return;
|
||||||
|
applyEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHandlePointerDown = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (!entryRef.current) return;
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
setDrag({ inputType: 'pointer', startY: e.clientY, deltaY: 0 });
|
||||||
|
};
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
|
applyMove(e.clientY, e);
|
||||||
|
};
|
||||||
|
const onPointerEnd = (e: PointerEvent) => {
|
||||||
|
if (e.pointerType === 'touch') return;
|
||||||
|
const d = dragRef.current;
|
||||||
|
if (!d || d.inputType !== 'pointer') return;
|
||||||
|
applyEnd();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleEl.addEventListener('touchstart', onHandleTouchStart, { passive: true });
|
||||||
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
|
document.addEventListener('touchcancel', onTouchEnd, { passive: true });
|
||||||
|
handleEl.addEventListener('pointerdown', onHandlePointerDown);
|
||||||
|
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||||
|
document.addEventListener('pointerup', onPointerEnd, { passive: true });
|
||||||
|
document.addEventListener('pointercancel', onPointerEnd, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
handleEl.removeEventListener('touchstart', onHandleTouchStart);
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
|
document.removeEventListener('touchend', onTouchEnd);
|
||||||
|
document.removeEventListener('touchcancel', onTouchEnd);
|
||||||
|
handleEl.removeEventListener('pointerdown', onHandlePointerDown);
|
||||||
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onPointerEnd);
|
||||||
|
document.removeEventListener('pointercancel', onPointerEnd);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Geometry — same emerge ramp as the settings / profile horseshoes
|
||||||
|
// (rationale at the top of `MobileSettingsHorseshoe`).
|
||||||
|
let horseshoeRamp: number;
|
||||||
|
if (isDragging) {
|
||||||
|
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
||||||
|
} else {
|
||||||
|
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
||||||
|
}
|
||||||
|
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||||
|
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||||
|
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||||
|
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
|
||||||
|
|
||||||
|
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
|
||||||
|
|
||||||
|
const silhouetteTransition = isDragging
|
||||||
|
? 'none'
|
||||||
|
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
|
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
|
|
||||||
|
const containerStyle: React.CSSProperties = {
|
||||||
|
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEntry = entry ?? lastEntryRef.current;
|
||||||
|
const renderBody = !!renderEntry && (keepMounted || isDragging);
|
||||||
|
|
||||||
|
// Marker portal for the global Android-back handler — when
|
||||||
|
// `portalContainer.firstChild` matches our `data-vojo-…-active`
|
||||||
|
// attribute, the back-button dispatches an Escape keydown that
|
||||||
|
// the `keydown` effect above catches.
|
||||||
|
const portalTarget =
|
||||||
|
typeof document !== 'undefined'
|
||||||
|
? document.getElementById('portalContainer') ?? document.body
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className={css.container} style={containerStyle}>
|
||||||
|
{open && portalTarget
|
||||||
|
? createPortal(
|
||||||
|
<div
|
||||||
|
data-vojo-media-viewer-sheet-active="true"
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>,
|
||||||
|
portalTarget
|
||||||
|
)
|
||||||
|
: null}
|
||||||
|
<div
|
||||||
|
className={css.appBody}
|
||||||
|
style={{
|
||||||
|
clipPath: appBodyClipPath,
|
||||||
|
transition: appBodyTransition,
|
||||||
|
overscrollBehaviorY: 'contain',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={css.silhouette}
|
||||||
|
style={{
|
||||||
|
height: `${expandedPx}px`,
|
||||||
|
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
||||||
|
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
||||||
|
transition: silhouetteTransition,
|
||||||
|
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||||
|
// Reset `--vojo-safe-top` so any descendant that paints a
|
||||||
|
// `padding-top: var(--vojo-safe-top)` for status-bar safe-
|
||||||
|
// area (e.g. PageNav-style headers, if anyone adds one
|
||||||
|
// here later) doesn't push the viewer header down inside
|
||||||
|
// the sheet — the sheet itself sits below the status bar.
|
||||||
|
// Mirror of the settings horseshoe.
|
||||||
|
['--vojo-safe-top' as string]: '0px',
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={renderEntry?.body ?? t('MediaViewer.title', 'Media viewer')}
|
||||||
|
>
|
||||||
|
<div className={css.panelContent} style={{ height: `${railHeightPx}px` }}>
|
||||||
|
<div
|
||||||
|
ref={handleRef}
|
||||||
|
className={css.panelHandle}
|
||||||
|
aria-label={t('MediaViewer.drag_to_close', 'Drag down to close')}
|
||||||
|
>
|
||||||
|
<div className={css.panelHandleBar} />
|
||||||
|
</div>
|
||||||
|
<div className={css.panelBody}>
|
||||||
|
{renderBody && renderEntry && (
|
||||||
|
<MediaViewerBody entry={renderEntry} requestClose={() => closeSheetRef.current()} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Top-level branch — pass through on non-mobile (the desktop right
|
||||||
|
// pane handles the same atom over there). The mobile branch owns
|
||||||
|
// the drag / atom hooks in a sub-component so we don't run them on
|
||||||
|
// desktop renders.
|
||||||
|
export function MobileMediaViewerHorseshoe({
|
||||||
|
children,
|
||||||
|
}: MobileMediaViewerHorseshoeProps): React.ReactElement {
|
||||||
|
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
|
if (!isMobile) {
|
||||||
|
return children as React.ReactElement;
|
||||||
|
}
|
||||||
|
return <MobileMediaViewerHorseshoeImpl>{children}</MobileMediaViewerHorseshoeImpl>;
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,10 @@ import { callChatAtom } from '../../state/callEmbed';
|
||||||
import { CallChatView } from './CallChatView';
|
import { CallChatView } from './CallChatView';
|
||||||
import { RoomViewProfilePanel } from './RoomViewProfilePanel';
|
import { RoomViewProfilePanel } from './RoomViewProfilePanel';
|
||||||
import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
|
import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel';
|
||||||
|
import { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
||||||
|
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
|
||||||
|
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
||||||
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode';
|
||||||
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
import { CHANNELS_THREAD_PATH } from '../../pages/paths';
|
||||||
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
import { getChannelsRoomPath } from '../../pages/pathUtils';
|
||||||
|
|
@ -110,7 +114,15 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// PageRoot. The chat column applies an explicit Background bg so the
|
// PageRoot. The chat column applies an explicit Background bg so the
|
||||||
// parent void can't bleed through any transparent slivers.
|
// parent void can't bleed through any transparent slivers.
|
||||||
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
||||||
|
const mediaOpen = !!useAtomValue(mediaViewerAtom);
|
||||||
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
||||||
|
// Media viewer side pane on desktop — same horseshoe seam idiom
|
||||||
|
// as the profile pane. The two are mutually exclusive in practice
|
||||||
|
// (the open hooks clear the other atom), so at most one shows at
|
||||||
|
// a time; both feed into `showAnyHorseshoe` for the chat-column
|
||||||
|
// bg + the void-gap render gate.
|
||||||
|
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
|
||||||
|
const showAnyHorseshoe = showProfileHorseshoe || showMediaHorseshoe;
|
||||||
|
|
||||||
useKeyDown(
|
useKeyDown(
|
||||||
window,
|
window,
|
||||||
|
|
@ -138,13 +150,34 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
|
|
||||||
const callView = room.isCallRoom();
|
const callView = room.isCallRoom();
|
||||||
|
|
||||||
|
// Disable the atom-driven media viewer when the desktop thread
|
||||||
|
// drawer is open — the side-pane mount block below is gated on
|
||||||
|
// `!showThreadDrawer`, so the new viewer's right pane wouldn't
|
||||||
|
// render, and an atom-only open path would silently no-op on the
|
||||||
|
// user's tap. With the host context cleared, `ImageContent` /
|
||||||
|
// `VideoContent` fall back to the legacy full-screen `<Overlay>`
|
||||||
|
// modal, which sits on top of every pane and still works.
|
||||||
|
// Mobile is unaffected: `drawerHidesChat` hides the chat column
|
||||||
|
// entirely on mobile + thread, so there's no media to tap anyway.
|
||||||
|
//
|
||||||
|
// `useMemo` so the value object identity is stable across
|
||||||
|
// unrelated re-renders — without it, every parent re-render
|
||||||
|
// would spread a new `{ roomId }` literal through the context,
|
||||||
|
// re-running every `useContext(MediaViewerHostContext)` consumer
|
||||||
|
// (every image / video thumbnail in the timeline).
|
||||||
|
const mediaHostValue = React.useMemo(
|
||||||
|
() => (!isMobile && showThreadDrawer ? null : { roomId: room.roomId }),
|
||||||
|
[isMobile, showThreadDrawer, room.roomId]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PowerLevelsContextProvider value={powerLevels}>
|
<PowerLevelsContextProvider value={powerLevels}>
|
||||||
<ThreadDrawerOpenProvider value={showThreadDrawer}>
|
<ThreadDrawerOpenProvider value={showThreadDrawer}>
|
||||||
|
<MediaViewerHostContext.Provider value={mediaHostValue}>
|
||||||
<Box
|
<Box
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
style={
|
style={
|
||||||
showProfileHorseshoe
|
showAnyHorseshoe
|
||||||
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
|
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -154,7 +187,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={
|
className={
|
||||||
showProfileHorseshoe
|
showAnyHorseshoe
|
||||||
? ContainerColor({ variant: 'Background' })
|
? ContainerColor({ variant: 'Background' })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -162,11 +195,13 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
|
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
|
||||||
// See the !callView twin block below for the rationale.
|
// See the !callView twin block below for the rationale.
|
||||||
>
|
>
|
||||||
|
<MobileMediaViewerHorseshoe>
|
||||||
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
|
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<CallView />
|
<CallView />
|
||||||
</Box>
|
</Box>
|
||||||
</RoomViewProfilePanel>
|
</RoomViewProfilePanel>
|
||||||
|
</MobileMediaViewerHorseshoe>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{!callView && !drawerHidesChat && (
|
{!callView && !drawerHidesChat && (
|
||||||
|
|
@ -174,7 +209,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
grow="Yes"
|
grow="Yes"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
className={
|
className={
|
||||||
showProfileHorseshoe
|
showAnyHorseshoe
|
||||||
? ContainerColor({ variant: 'Background' })
|
? ContainerColor({ variant: 'Background' })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
|
|
@ -187,23 +222,26 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// drags. Desktop branch still works because `--vojo-safe-top`
|
// drags. Desktop branch still works because `--vojo-safe-top`
|
||||||
// resolves to 0 on web.
|
// resolves to 0 on web.
|
||||||
>
|
>
|
||||||
|
<MobileMediaViewerHorseshoe>
|
||||||
<RoomViewProfilePanel header={<RoomViewHeader />}>
|
<RoomViewProfilePanel header={<RoomViewHeader />}>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
||||||
</Box>
|
</Box>
|
||||||
</RoomViewProfilePanel>
|
</RoomViewProfilePanel>
|
||||||
|
</MobileMediaViewerHorseshoe>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tablet / Desktop: profile renders as a third pane to the
|
{/* Tablet / Desktop: profile or media renders as a third pane
|
||||||
right of the chat. Mobile uses the top horseshoe inside
|
to the right of the chat. Mobile uses the top horseshoe
|
||||||
`RoomViewProfilePanel`, so we don't mount the side pane
|
(profile) and the bottom horseshoe (media), so we don't
|
||||||
there. The 12px void gap (same as page-nav <-> chat split
|
mount the side panes there. The 12px void gap sits between
|
||||||
from PageRoot) sits between the chat column and the pane
|
the chat column and whichever pane is open — both panes
|
||||||
so the seam reads identically to the rest of the app. */}
|
share the same gap geometry since they're mutually
|
||||||
|
exclusive via the open hooks. */}
|
||||||
{!isMobile && !showThreadDrawer && (
|
{!isMobile && !showThreadDrawer && (
|
||||||
<>
|
<>
|
||||||
{showProfileHorseshoe && (
|
{showAnyHorseshoe && (
|
||||||
<Box
|
<Box
|
||||||
shrink="No"
|
shrink="No"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -213,6 +251,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<RoomViewProfileSidePanel />
|
<RoomViewProfileSidePanel />
|
||||||
|
<RoomViewMediaSidePanel />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -248,6 +287,7 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</MediaViewerHostContext.Provider>
|
||||||
</ThreadDrawerOpenProvider>
|
</ThreadDrawerOpenProvider>
|
||||||
</PowerLevelsContextProvider>
|
</PowerLevelsContextProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
38
src/app/features/room/RoomViewMediaSidePanel.css.ts
Normal file
38
src/app/features/room/RoomViewMediaSidePanel.css.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, toRem } from 'folds';
|
||||||
|
import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||||
|
|
||||||
|
// Right-side media pane. Much wider than the profile pane —
|
||||||
|
// images need room to read comfortably. `clamp(480px, 50vw, 880px)`
|
||||||
|
// gives a generous minimum on narrow desktops while capping width
|
||||||
|
// on ultra-wide displays.
|
||||||
|
//
|
||||||
|
// Left edge rounded (TL + BL) to carve across the 12px horseshoe
|
||||||
|
// void gap rendered by `Room.tsx` — same design language as the
|
||||||
|
// profile pane and the page-nav <-> chat split.
|
||||||
|
//
|
||||||
|
// `Background.Container` (#0d0e11) chosen for the dark image
|
||||||
|
// backdrop — same logic as the mobile silhouette bg.
|
||||||
|
export const panel = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
width: `clamp(${toRem(480)}, 50vw, ${toRem(880)})`,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
|
borderBottomLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body fills the remaining vertical space below `MediaViewerBody`'s
|
||||||
|
// own header. The pane has no separate `PageHeader` strip — the
|
||||||
|
// viewer body owns its chrome (close button, title, action row) so
|
||||||
|
// the header sits flush with the rounded TL corner.
|
||||||
|
export const body = style({
|
||||||
|
flex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
minWidth: 0,
|
||||||
|
});
|
||||||
66
src/app/features/room/RoomViewMediaSidePanel.tsx
Normal file
66
src/app/features/room/RoomViewMediaSidePanel.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Desktop / tablet right-side media pane. Renders the same
|
||||||
|
// `MediaViewerBody` the mobile bottom-up horseshoe shows, but as a
|
||||||
|
// flex sibling next to the chat column instead of a slide-up rail.
|
||||||
|
//
|
||||||
|
// Mounted in `Room.tsx` only on non-mobile screens; mobile uses
|
||||||
|
// `MobileMediaViewerHorseshoe` instead.
|
||||||
|
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
||||||
|
import { useCloseMediaViewer } from '../../state/hooks/mediaViewer';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import { MediaViewerBody } from './MediaViewerBody';
|
||||||
|
import * as css from './RoomViewMediaSidePanel.css';
|
||||||
|
|
||||||
|
export function RoomViewMediaSidePanel() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const entry = useAtomValue(mediaViewerAtom);
|
||||||
|
const close = useCloseMediaViewer();
|
||||||
|
|
||||||
|
const open = !!entry;
|
||||||
|
|
||||||
|
const entryRef = useRef(entry);
|
||||||
|
entryRef.current = entry;
|
||||||
|
|
||||||
|
if (!open || !entry) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
// Move focus into the trap on open so screen readers
|
||||||
|
// announce the dialog and keyboard arrow keys / Esc reach
|
||||||
|
// the viewer body. Falls back to first focusable element
|
||||||
|
// (the close button in the body header) without us pinning
|
||||||
|
// a specific ref — simpler than threading one down.
|
||||||
|
initialFocus: undefined,
|
||||||
|
// Outside clicks pass through to chat (`allowOutsideClick`)
|
||||||
|
// but don't close — clicking around in chat to scroll /
|
||||||
|
// compose shouldn't dismiss the viewer. Explicit close via
|
||||||
|
// the body's × button or `Esc`. Mirror of the profile side
|
||||||
|
// pane's stance.
|
||||||
|
clickOutsideDeactivates: false,
|
||||||
|
allowOutsideClick: () => true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
onDeactivate: () => {
|
||||||
|
if (entryRef.current) close();
|
||||||
|
},
|
||||||
|
checkCanFocusTrap: () => Promise.resolve(),
|
||||||
|
}}
|
||||||
|
active={open}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css.panel}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={entry.body || t('MediaViewer.title', 'Media viewer')}
|
||||||
|
>
|
||||||
|
<div className={css.body}>
|
||||||
|
<MediaViewerBody entry={entry} requestClose={close} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/app/features/room/mediaViewerHostContext.ts
Normal file
20
src/app/features/room/mediaViewerHostContext.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
// Non-null only when an atom-driven media-viewer shell (the mobile
|
||||||
|
// bottom-up horseshoe or the desktop right side pane) is mounted in
|
||||||
|
// the React tree above this consumer. `ImageContent` reads this to
|
||||||
|
// decide between the new atom-open path (Room timeline) and the
|
||||||
|
// legacy local-state `<Overlay>` viewer (pin-menu, message search
|
||||||
|
// — surfaces where no shell is mounted). Carries `roomId` so the
|
||||||
|
// consumer doesn't need a separate `useRoom()` call (which throws
|
||||||
|
// outside `RoomProvider`).
|
||||||
|
//
|
||||||
|
// Defaults to `null` so unchanged callers keep their current
|
||||||
|
// behaviour; `Room.tsx` provides a non-null value for the chat
|
||||||
|
// column.
|
||||||
|
export type MediaViewerHostValue = { roomId: string } | null;
|
||||||
|
|
||||||
|
export const MediaViewerHostContext = createContext<MediaViewerHostValue>(null);
|
||||||
|
|
||||||
|
export const useMediaViewerHost = (): MediaViewerHostValue =>
|
||||||
|
useContext(MediaViewerHostContext);
|
||||||
28
src/app/state/hooks/mediaViewer.ts
Normal file
28
src/app/state/hooks/mediaViewer.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { mediaViewerAtom, MediaViewerEntry } from '../mediaViewer';
|
||||||
|
import { userRoomProfileAtom } from '../userRoomProfile';
|
||||||
|
|
||||||
|
export const useOpenMediaViewer = (): ((entry: MediaViewerEntry) => void) => {
|
||||||
|
const setMedia = useSetAtom(mediaViewerAtom);
|
||||||
|
// Close the profile side pane / horseshoe when opening media —
|
||||||
|
// having both open simultaneously fights for chat-column width on
|
||||||
|
// desktop and stacks two horseshoe surfaces on mobile. Mutual
|
||||||
|
// exclusivity is the simplest contract until the user asks for
|
||||||
|
// stacking.
|
||||||
|
const setProfile = useSetAtom(userRoomProfileAtom);
|
||||||
|
return useCallback(
|
||||||
|
(entry) => {
|
||||||
|
setProfile(undefined);
|
||||||
|
setMedia(entry);
|
||||||
|
},
|
||||||
|
[setMedia, setProfile]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useCloseMediaViewer = (): (() => void) => {
|
||||||
|
const setMedia = useSetAtom(mediaViewerAtom);
|
||||||
|
return useCallback(() => {
|
||||||
|
setMedia(undefined);
|
||||||
|
}, [setMedia]);
|
||||||
|
};
|
||||||
|
|
@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Position, RectCords } from 'folds';
|
import { Position, RectCords } from 'folds';
|
||||||
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
|
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
|
||||||
|
import { mediaViewerAtom } from '../mediaViewer';
|
||||||
|
|
||||||
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
|
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
|
||||||
const data = useAtomValue(userRoomProfileAtom);
|
const data = useAtomValue(userRoomProfileAtom);
|
||||||
|
|
@ -29,12 +30,20 @@ type OpenCallback = (
|
||||||
) => void;
|
) => void;
|
||||||
export const useOpenUserRoomProfile = (): OpenCallback => {
|
export const useOpenUserRoomProfile = (): OpenCallback => {
|
||||||
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
const setUserRoomProfile = useSetAtom(userRoomProfileAtom);
|
||||||
|
// Bidirectional mutual exclusion with the media viewer — opening
|
||||||
|
// profile closes any open media viewer pane / sheet. Mirrors the
|
||||||
|
// clear-the-other-atom behaviour in `useOpenMediaViewer`. Without
|
||||||
|
// this, on desktop both `RoomViewProfileSidePanel` and
|
||||||
|
// `RoomViewMediaSidePanel` would mount as siblings and fight for
|
||||||
|
// the right-pane slot.
|
||||||
|
const setMediaViewer = useSetAtom(mediaViewerAtom);
|
||||||
|
|
||||||
const open: OpenCallback = useCallback(
|
const open: OpenCallback = useCallback(
|
||||||
(roomId, spaceId, userId, cords, position) => {
|
(roomId, spaceId, userId, cords, position) => {
|
||||||
|
setMediaViewer(undefined);
|
||||||
setUserRoomProfile({ roomId, spaceId, userId, cords, position });
|
setUserRoomProfile({ roomId, spaceId, userId, cords, position });
|
||||||
},
|
},
|
||||||
[setUserRoomProfile]
|
[setUserRoomProfile, setMediaViewer]
|
||||||
);
|
);
|
||||||
|
|
||||||
return open;
|
return open;
|
||||||
|
|
|
||||||
31
src/app/state/mediaViewer.ts
Normal file
31
src/app/state/mediaViewer.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||||
|
import { IImageInfo, IVideoInfo } from '../../types/matrix/common';
|
||||||
|
|
||||||
|
// Open state for the room media viewer — bottom-up sheet on mobile,
|
||||||
|
// right-side pane on desktop. Mirror of `settingsSheetAtom`. The
|
||||||
|
// atom-driven shell is mounted in `Room.tsx`; `ImageContent` /
|
||||||
|
// `VideoContent` open the atom when the host context from
|
||||||
|
// `mediaViewerHostContext` is non-null (Room timeline path);
|
||||||
|
// otherwise they fall back to legacy local-state `<Overlay>` or
|
||||||
|
// inline native playback (pin-menu, message search — surfaces where
|
||||||
|
// the horseshoe shell isn't mounted).
|
||||||
|
export type MediaViewerKind = 'image' | 'video';
|
||||||
|
|
||||||
|
export type MediaViewerEntry = {
|
||||||
|
roomId: string;
|
||||||
|
eventId: string;
|
||||||
|
kind: MediaViewerKind;
|
||||||
|
// mxc:// URL (unresolved). The viewer body resolves + decrypts
|
||||||
|
// internally using the same code paths the inline thumbnail uses
|
||||||
|
// (`mxcUrlToHttp` + `downloadEncryptedMedia` + `decryptFile`), so
|
||||||
|
// the atom payload stays cheap to construct from a thumbnail-tap
|
||||||
|
// event handler without awaiting decryption.
|
||||||
|
url: string;
|
||||||
|
body: string;
|
||||||
|
info?: IImageInfo | IVideoInfo;
|
||||||
|
encInfo?: EncryptedAttachmentInfo;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mediaViewerAtom = atom<MediaViewerEntry | undefined>(undefined);
|
||||||
Loading…
Add table
Reference in a new issue