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
e5a85b9b88
commit
b1b9631724
15 changed files with 2046 additions and 28 deletions
|
|
@ -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
|
||||
? () => (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
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 { 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>
|
||||
);
|
||||
|
|
|
|||
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 { 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;
|
||||
|
|
|
|||
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