Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-media-viewer-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add arrow-key and chevron navigation to the media viewer: zooming into an attachment bundle or a multi-image gallery navigates between its images with wrap-around, dimmed edge buttons, neighbour preloading, and hover-revealed controls. Staged composer attachments can be zoomed in before sending and navigate the same way.
79 changes: 73 additions & 6 deletions src/app/components/RenderMessageContent.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { CSSProperties, JSX } from 'react';
import { memo, useMemo, useCallback } from 'react';
import type { CSSProperties, JSX, ReactNode } from 'react';
import { memo, useMemo, useCallback, useState } from 'react';
import type { IPreviewUrlResponse, MatrixClient, MatrixEvent, Room } from '$types/matrix-sdk';
import { MsgType } from '$types/matrix-sdk';
import { parseSettingsLink } from '$features/settings/settingsLink';
Expand Down Expand Up @@ -46,6 +46,7 @@ import { isHttpsFullSableCssUrl } from '../theme/previewUrls';
import { isSableCssAttachmentFileName } from '../theme/processThemeImport';
import { Image, MediaControl, PersistedVolumeVideo } from './media';
import { ImageViewer } from './image-viewer';
import { RoomMediaViewer } from './image-viewer/RoomMediaViewer';
import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer';
import { ClientSideHoverFreeze } from './ClientSideHoverFreeze';
Expand All @@ -54,6 +55,7 @@ import { PollEvent } from './message/PollEvent';
import { M_POLL_START, M_TEXT } from 'matrix-js-sdk';
import type { IImageInfo, IGalleryContent } from '$types/matrix/common';
import { GALLERY_MSGTYPE } from '$types/matrix/common';
import { getGalleryMediaItems } from '$features/room/mediaBundle';
import { parseExternalGif } from '$utils/externalGif';
import { parseLegacyKlipyGif } from '$utils/klipy';
import {
Expand Down Expand Up @@ -88,6 +90,7 @@ type RenderMessageContentProps = {
mx?: MatrixClient;
room?: Room;
onOpenMedia?: (mEvent: MatrixEvent) => boolean;
onOpenViewerOverride?: () => boolean;
};

const getMediaType = (url: string) => {
Expand All @@ -106,6 +109,64 @@ const CAPTION_STYLE: CSSProperties = { marginTop: config.space.S200, maxWidth: '
const TEXT_STYLE: CSSProperties = { maxWidth: '100%' };
const EXTERNAL_GIF_MAX_SIZE = 400;

// Clicking an image opens the gallery in a RoomMediaViewer so arrows and
// chevrons navigate its items; single-image galleries keep their own viewers.
function GalleryContent({
content,
mEvent,
displayName,
renderItem,
}: {
content: IGalleryContent;
mEvent?: MatrixEvent;
displayName: string;
renderItem: (itemContent: unknown, claimOpen: (() => boolean) | undefined) => ReactNode;
}) {
const [viewId, setViewId] = useState<string>();
const mediaItems = useMemo(
() => getGalleryMediaItems(mEvent, content, displayName),
[mEvent, content, displayName]
);
// MGallery partitions items into grids, so its render index is not the itemtypes
// index — match the clicked item back to its viewer entry by media url.
const eventIdByUrl = useMemo(
() => new Map(mediaItems.map((item) => [item.url, item.eventId])),
[mediaItems]
);

return (
<>
<MGallery
content={content}
renderItem={(itemContent) => {
const url =
(itemContent as { file?: { url?: string }; url?: string }).file?.url ??
(itemContent as { url?: string }).url;
const eventId =
mediaItems.length > 1 && typeof url === 'string' ? eventIdByUrl.get(url) : undefined;
return renderItem(
itemContent,
eventId
? () => {
setViewId(eventId);
return true;
}
: undefined
);
}}
/>
{mediaItems.length > 1 && viewId && (
<RoomMediaViewer
items={mediaItems}
selectedEventId={viewId}
selectEvent={setViewId}
requestClose={() => setViewId(undefined)}
/>
)}
</>
);
}

function RenderMessageContentInternal({
displayName,
msgType,
Expand All @@ -127,6 +188,7 @@ function RenderMessageContentInternal({
mx,
room,
onOpenMedia,
onOpenViewerOverride,
}: RenderMessageContentProps) {
const content = useMemo(() => getContent() as Record<string, unknown>, [getContent]);

Expand Down Expand Up @@ -510,7 +572,9 @@ function RenderMessageContentInternal({
renderImageContent={(imageProps) => (
<ImageContent
{...imageProps}
onOpenViewer={mEvent ? () => onOpenMedia?.(mEvent) ?? false : undefined}
onOpenViewer={
onOpenViewerOverride ?? (mEvent ? () => onOpenMedia?.(mEvent) ?? false : undefined)
}
autoPlay={mediaAutoLoad}
renderImage={(p) => {
if (isGif && !autoplayGifs && p.src) {
Expand Down Expand Up @@ -582,12 +646,14 @@ function RenderMessageContentInternal({

if (msgType === GALLERY_MSGTYPE) {
return renderCaptionedAttachment(
<MGallery
<GalleryContent
content={content as IGalleryContent}
renderItem={(itemContent) => (
mEvent={mEvent}
displayName={displayName}
renderItem={(itemContent, claimOpen) => (
<RenderMessageContentInternal
displayName={displayName}
msgType={itemContent.msgtype as string}
msgType={(itemContent as { msgtype?: string }).msgtype as string}
ts={ts}
getContent={() => itemContent}
mediaAutoLoad={mediaAutoLoad}
Expand All @@ -597,6 +663,7 @@ function RenderMessageContentInternal({
linkifyOpts={linkifyOpts}
outlineAttachment={outlineAttachment}
isGallery={true}
onOpenViewerOverride={claimOpen}
/>
)}
/>
Expand Down
42 changes: 41 additions & 1 deletion src/app/components/image-viewer/ImageViewer.css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,16 +148,56 @@ const mobileGalleryControl = {
top: '50%',
zIndex: 1,
transform: 'translateY(-50%)',
backgroundColor: '#0009',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
color: '#fff',
transition: 'background-color 150ms ease, opacity 150ms ease',
};

// Desktop hides the chevrons until the cursor is over the viewer; keyboard
// users can still tab to them, which reveals them again.
export const ImageViewerControlsHidden = style({
opacity: 0,
pointerEvents: 'none',
selectors: {
'&:focus-visible': {
opacity: 1,
pointerEvents: 'auto',
},
},
});

// The global `button:hover` lift would replace the -50% centering transform and
// bounce the hitbox under the cursor, so re-assert it here and only fade color.
const galleryControlStates = {
selectors: {
'&:hover': {
transform: 'translateY(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
'&:focus-visible': {
transform: 'translateY(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
'&:active': {
transform: 'translateY(-50%)',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
},
},
};

export const ImageViewerPrevious = style({
...mobileGalleryControl,
...galleryControlStates,
left: `calc(${config.space.S100} + ${safeAreaLeft})`,
});

export const ImageViewerNext = style({
...mobileGalleryControl,
...galleryControlStates,
right: `calc(${config.space.S100} + ${safeAreaRight})`,
});

// Dimmed rest state for the button whose direction wraps around the bundle end.
export const ImageViewerEdge = style({
backgroundColor: 'rgba(0, 0, 0, 0.2)',
});
42 changes: 38 additions & 4 deletions src/app/components/image-viewer/ImageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ type ImageViewerProps = {
sentAt?: string;
onPrevious?: () => void;
onNext?: () => void;
atStart?: boolean;
atEnd?: boolean;
getDownloadBlob?: () => Promise<Blob>;
};

Expand All @@ -80,12 +82,15 @@ export const ImageViewer = as<'div', ImageViewerProps>(
sentAt,
onPrevious,
onNext,
atStart,
atEnd,
getDownloadBlob,
...props
},
ref
) => {
const zoomInputRef = useRef<HTMLInputElement>(null);
const [controlsVisible, setControlsVisible] = useState(false);
const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering');
const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile;

Expand All @@ -100,6 +105,25 @@ export const ImageViewer = as<'div', ImageViewerProps>(
// Android back closes the viewer instead of navigating away.
useDismissOnBack(requestClose);

// Arrow keys navigate a media gallery; the zoom input and menu buttons keep
// their own key handling.
useEffect(() => {
if (!onPrevious && !onNext) return undefined;
const handleKeyDown = (evt: KeyboardEvent) => {
const target = evt.target;
if (
target instanceof HTMLElement &&
target.closest('input, textarea, button, [role="menuitem"]')
) {
return;
}
if (evt.key === 'ArrowLeft') onPrevious?.();
else if (evt.key === 'ArrowRight') onNext?.();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onPrevious, onNext]);

const [isImageReady, setIsImageReady] = useState(false);
const [isEditingZoom, setIsEditingZoom] = useState(false);
const [zoomInput, setZoomInput] = useState('100');
Expand Down Expand Up @@ -616,14 +640,20 @@ export const ImageViewer = as<'div', ImageViewerProps>(
style={{ overflow: 'hidden', touchAction: 'none', cursor }}
onPointerDown={onPointerDown}
onContextMenu={handleContextMenu}
onMouseEnter={() => !isMobile && setControlsVisible(true)}
onMouseLeave={() => setControlsVisible(false)}
onTouchStart={menu.triggerProps.onTouchStart}
onTouchEnd={menu.triggerProps.onTouchEnd}
onTouchMove={menu.triggerProps.onTouchMove}
onTouchCancel={menu.triggerProps.onTouchCancel}
>
{isMobile && onPrevious && (
{onPrevious && (
<IconButton
className={css.ImageViewerPrevious}
className={classNames(
css.ImageViewerPrevious,
atStart && css.ImageViewerEdge,
!isMobile && !controlsVisible && css.ImageViewerControlsHidden
)}
aria-label="Previous image"
size="400"
radii="300"
Expand All @@ -632,9 +662,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
{sizedIcon(CaretLeft, '200')}
</IconButton>
)}
{isMobile && onNext && (
{onNext && (
<IconButton
className={css.ImageViewerNext}
className={classNames(
css.ImageViewerNext,
atEnd && css.ImageViewerEdge,
!isMobile && !controlsVisible && css.ImageViewerControlsHidden
)}
aria-label="Next image"
size="400"
radii="300"
Expand Down
Loading