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:
v.lagerev 2026-05-13 02:47:04 +03:00
parent e5a85b9b88
commit b1b9631724
15 changed files with 2046 additions and 28 deletions

View file

@ -65,6 +65,12 @@ type RenderMessageContentProps = {
htmlReactParserOptions: HTMLReactParserOptions;
linkifyOpts: Opts;
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({
displayName,
@ -78,6 +84,7 @@ export function RenderMessageContent({
htmlReactParserOptions,
linkifyOpts,
outlineAttachment,
eventId,
}: RenderMessageContentProps) {
const streamMedia = useStreamMediaContext();
const renderUrlsPreview = (urls: string[]) => {
@ -219,6 +226,7 @@ export function RenderMessageContent({
<ImageContent
{...props}
autoPlay={mediaAutoLoad}
eventId={eventId}
renderImage={(p) => <Image {...p} loading="lazy" decoding="async" />}
renderViewer={(p) => <ImageViewer {...p} />}
/>
@ -258,6 +266,7 @@ export function RenderMessageContent({
body={body}
info={info}
{...props}
eventId={eventId}
renderThumbnail={
mediaAutoLoad
? () => (

View file

@ -31,6 +31,8 @@ import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../util
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { ModalWide } from '../../../styles/Modal.css';
import { validBlurHash } from '../../../utils/blurHash';
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
type RenderViewerProps = {
src: string;
@ -44,7 +46,17 @@ type RenderImageProps = {
onLoad: () => void;
onError: () => void;
onClick: () => void;
onKeyDown: (e: React.KeyboardEvent<HTMLImageElement>) => void;
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 = {
body: string;
@ -55,6 +67,13 @@ export type ImageContentProps = {
autoPlay?: boolean;
markedAsSpoiler?: boolean;
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;
renderImage: (props: RenderImageProps) => ReactNode;
};
@ -70,6 +89,7 @@ export const ImageContent = as<'div', ImageContentProps>(
autoPlay,
markedAsSpoiler,
spoilerReason,
eventId,
renderViewer,
renderImage,
...props
@ -79,12 +99,37 @@ export const ImageContent = as<'div', ImageContentProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
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 [error, setError] = useState(false);
const [viewer, setViewer] = useState(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(
useCallback(async () => {
const mediaUrl = mxcUrlToHttp(mx, url, useAuthentication);
@ -118,7 +163,7 @@ export const ImageContent = as<'div', ImageContentProps>(
return (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
{srcState.status === AsyncStatus.Success && (
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
<Overlay open={viewer} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
@ -168,15 +213,28 @@ export const ImageContent = as<'div', ImageContentProps>(
</Box>
)}
{srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
<Box
className={classNames(
css.AbsoluteContainer,
blurred ? css.Blur : css.ImageClickable
)}
>
{renderImage({
alt: body,
title: body,
src: srcState.data,
onLoad: handleLoad,
onError: handleError,
onClick: () => setViewer(true),
onClick: handleOpen,
onKeyDown: (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleOpen();
}
},
tabIndex: 0,
role: 'button',
'aria-label': body || 'Open media',
})}
</Box>
)}

View file

@ -32,6 +32,8 @@ import {
} from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { validBlurHash } from '../../../utils/blurHash';
import { useMediaViewerHost } from '../../../features/room/mediaViewerHostContext';
import { useOpenMediaViewer } from '../../../state/hooks/mediaViewer';
type RenderVideoProps = {
title: string;
@ -50,6 +52,14 @@ type VideoContentProps = {
autoPlay?: boolean;
markedAsSpoiler?: boolean;
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;
renderVideo: (props: RenderVideoProps) => ReactNode;
};
@ -65,6 +75,7 @@ export const VideoContent = as<'div', VideoContentProps>(
autoPlay,
markedAsSpoiler,
spoilerReason,
eventId,
renderThumbnail,
renderVideo,
...props
@ -74,6 +85,9 @@ export const VideoContent = as<'div', VideoContentProps>(
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
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 [error, setError] = useState(false);
@ -106,8 +120,29 @@ export const VideoContent = as<'div', VideoContentProps>(
};
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();
}, [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 (
<Box className={classNames(css.RelativeBase, className)} {...props} ref={ref}>
@ -129,7 +164,21 @@ export const VideoContent = as<'div', VideoContentProps>(
{renderThumbnail()}
</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">
<Button
variant="Secondary"
@ -143,7 +192,7 @@ export const VideoContent = as<'div', VideoContentProps>(
</Button>
</Box>
)}
{srcState.status === AsyncStatus.Success && (
{!useAtomViewer && srcState.status === AsyncStatus.Success && (
<Box className={classNames(css.AbsoluteContainer, blurred && css.Blur)}>
{renderVideo({
title: body,

View file

@ -1,6 +1,36 @@
import { style } from '@vanilla-extract/css';
import { globalStyle, style } from '@vanilla-extract/css';
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([
DefaultReset,
{

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

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

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

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

View file

@ -24,6 +24,10 @@ import { callChatAtom } from '../../state/callEmbed';
import { CallChatView } from './CallChatView';
import { RoomViewProfilePanel } from './RoomViewProfilePanel';
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 { CHANNELS_THREAD_PATH } from '../../pages/paths';
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
// parent void can't bleed through any transparent slivers.
const profileOpen = !!useAtomValue(userRoomProfileAtom);
const mediaOpen = !!useAtomValue(mediaViewerAtom);
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(
window,
@ -138,13 +150,34 @@ export function Room({ renderRoomView }: RoomProps) {
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 (
<PowerLevelsContextProvider value={powerLevels}>
<ThreadDrawerOpenProvider value={showThreadDrawer}>
<MediaViewerHostContext.Provider value={mediaHostValue}>
<Box
grow="Yes"
style={
showProfileHorseshoe
showAnyHorseshoe
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
: undefined
}
@ -154,7 +187,7 @@ export function Room({ renderRoomView }: RoomProps) {
grow="Yes"
direction="Column"
className={
showProfileHorseshoe
showAnyHorseshoe
? ContainerColor({ variant: 'Background' })
: undefined
}
@ -162,11 +195,13 @@ export function Room({ renderRoomView }: RoomProps) {
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
// See the !callView twin block below for the rationale.
>
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
<MobileMediaViewerHorseshoe>
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
</MobileMediaViewerHorseshoe>
</Box>
)}
{!callView && !drawerHidesChat && (
@ -174,7 +209,7 @@ export function Room({ renderRoomView }: RoomProps) {
grow="Yes"
direction="Column"
className={
showProfileHorseshoe
showAnyHorseshoe
? ContainerColor({ variant: 'Background' })
: undefined
}
@ -187,23 +222,26 @@ export function Room({ renderRoomView }: RoomProps) {
// drags. Desktop branch still works because `--vojo-safe-top`
// resolves to 0 on web.
>
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box>
</RoomViewProfilePanel>
<MobileMediaViewerHorseshoe>
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box>
</RoomViewProfilePanel>
</MobileMediaViewerHorseshoe>
</Box>
)}
{/* Tablet / Desktop: profile renders as a third pane to the
right of the chat. Mobile uses the top horseshoe inside
`RoomViewProfilePanel`, so we don't mount the side pane
there. The 12px void gap (same as page-nav <-> chat split
from PageRoot) sits between the chat column and the pane
so the seam reads identically to the rest of the app. */}
{/* Tablet / Desktop: profile or media renders as a third pane
to the right of the chat. Mobile uses the top horseshoe
(profile) and the bottom horseshoe (media), so we don't
mount the side panes there. The 12px void gap sits between
the chat column and whichever pane is open both panes
share the same gap geometry since they're mutually
exclusive via the open hooks. */}
{!isMobile && !showThreadDrawer && (
<>
{showProfileHorseshoe && (
{showAnyHorseshoe && (
<Box
shrink="No"
style={{
@ -213,6 +251,7 @@ export function Room({ renderRoomView }: RoomProps) {
/>
)}
<RoomViewProfileSidePanel />
<RoomViewMediaSidePanel />
</>
)}
@ -248,6 +287,7 @@ export function Room({ renderRoomView }: RoomProps) {
/>
)}
</Box>
</MediaViewerHostContext.Provider>
</ThreadDrawerOpenProvider>
</PowerLevelsContextProvider>
);

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

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

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

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

View file

@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { Position, RectCords } from 'folds';
import { userRoomProfileAtom, UserRoomProfileState } from '../userRoomProfile';
import { mediaViewerAtom } from '../mediaViewer';
export const useUserRoomProfileState = (): UserRoomProfileState | undefined => {
const data = useAtomValue(userRoomProfileAtom);
@ -29,12 +30,20 @@ type OpenCallback = (
) => void;
export const useOpenUserRoomProfile = (): OpenCallback => {
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(
(roomId, spaceId, userId, cords, position) => {
setMediaViewer(undefined);
setUserRoomProfile({ roomId, spaceId, userId, cords, position });
},
[setUserRoomProfile]
[setUserRoomProfile, setMediaViewer]
);
return open;

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