diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index 71700e82..45f9aaf5 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -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 + // `` 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({ } renderViewer={(p) => } /> @@ -258,6 +266,7 @@ export function RenderMessageContent({ body={body} info={info} {...props} + eventId={eventId} renderThumbnail={ mediaAutoLoad ? () => ( diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 4f1d9c75..90122b24 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -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) => 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 `` — we keep the bare `` 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 `` 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 ( - {srcState.status === AsyncStatus.Success && ( + {!useAtomViewer && srcState.status === AsyncStatus.Success && ( }> ( )} {srcState.status === AsyncStatus.Success && ( - + {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', })} )} diff --git a/src/app/components/message/content/VideoContent.tsx b/src/app/components/message/content/VideoContent.tsx index b33ec272..09640f61 100644 --- a/src/app/components/message/content/VideoContent.tsx +++ b/src/app/components/message/content/VideoContent.tsx @@ -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 ( @@ -129,7 +164,21 @@ export const VideoContent = as<'div', VideoContentProps>( {renderThumbnail()} )} - {!autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( + {useAtomViewer && !blurred && ( + + + + )} + {!useAtomViewer && !autoPlay && !blurred && srcState.status === AsyncStatus.Idle && ( )} - {srcState.status === AsyncStatus.Success && ( + {!useAtomViewer && srcState.status === AsyncStatus.Success && ( {renderVideo({ title: body, diff --git a/src/app/components/message/content/style.css.ts b/src/app/components/message/content/style.css.ts index bb5d8484..dadcbe3a 100644 --- a/src/app/components/message/content/style.css.ts +++ b/src/app/components/message/content/style.css.ts @@ -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 `` 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 `` (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, { diff --git a/src/app/features/room/MediaViewerBody.css.ts b/src/app/features/room/MediaViewerBody.css.ts new file mode 100644 index 00000000..b5f3a2e6 --- /dev/null +++ b/src/app/features/room/MediaViewerBody.css.ts @@ -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 . `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, +}); diff --git a/src/app/features/room/MediaViewerBody.tsx b/src/app/features/room/MediaViewerBody.tsx new file mode 100644 index 00000000..a03ac2e5 --- /dev/null +++ b/src/app/features/room/MediaViewerBody.tsx @@ -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 `