From fc08bde7701a2f2ed917608986b8106d8bb53e96 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Thu, 30 Jul 2026 15:12:47 +0200 Subject: [PATCH] feat(mobile): add room media viewer --- .changeset/mobile-room-media-viewer.md | 5 + .../java/moe/sable/client/MainActivity.kt | 22 + src-tauri/src/lib.rs | 2 + src-tauri/src/mobile.rs | 17 + src/app/components/MobileSwipeDownModal.tsx | 7 +- src/app/components/RenderMessageContent.tsx | 3 + src/app/components/ResponsiveMenu.tsx | 45 +- .../image-viewer/ImageViewer.css.ts | 37 ++ .../image-viewer/ImageViewer.test.tsx | 26 +- .../components/image-viewer/ImageViewer.tsx | 520 ++++++++++++------ .../image-viewer/RoomMediaViewer.tsx | 130 +++++ .../message/content/ImageContent.test.tsx | 2 +- .../message/content/ImageContent.tsx | 47 +- .../components/modal-overlay/ModalOverlay.tsx | 9 +- src/app/features/room/RoomTimeline.tsx | 56 +- .../timeline/useTimelineEventRenderer.tsx | 7 + src/app/hooks/useImageGestures.ts | 47 +- src/app/hooks/useMobileTapActivation.test.tsx | 49 ++ src/app/hooks/useMobileTapActivation.ts | 30 + 19 files changed, 853 insertions(+), 208 deletions(-) create mode 100644 .changeset/mobile-room-media-viewer.md create mode 100644 src/app/components/image-viewer/RoomMediaViewer.tsx create mode 100644 src/app/hooks/useMobileTapActivation.test.tsx diff --git a/.changeset/mobile-room-media-viewer.md b/.changeset/mobile-room-media-viewer.md new file mode 100644 index 0000000000..ba6693257f --- /dev/null +++ b/.changeset/mobile-room-media-viewer.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add a fullscreen mobile room media viewer with gallery navigation and image gestures. diff --git a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt index c5b3096f13..9256deaba0 100644 --- a/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt +++ b/src-tauri/gen/android/app/src/main/java/moe/sable/client/MainActivity.kt @@ -15,6 +15,7 @@ import androidx.activity.enableEdgeToEdge import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import java.io.File import java.io.FileOutputStream import java.util.UUID @@ -153,6 +154,7 @@ class MainActivity : TauriActivity() { companion object { private var instance: MainActivity? = null + private var immersiveSystemBarsBehavior: Int? = null // Bars stay transparent (edge-to-edge plugin) so the webview strips supply the color // on every version; these only adapt icon contrast. setStatusBarColor/setNavigationBarColor @@ -177,6 +179,26 @@ class MainActivity : TauriActivity() { } } + @JvmStatic + fun setImmersiveModeNative(enabled: Boolean) { + val activity = instance ?: return + activity.runOnUiThread { + val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView) + if (enabled) { + if (immersiveSystemBarsBehavior == null) { + immersiveSystemBarsBehavior = controller.systemBarsBehavior + } + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } else { + controller.show(WindowInsetsCompat.Type.systemBars()) + immersiveSystemBarsBehavior?.let { controller.systemBarsBehavior = it } + immersiveSystemBarsBehavior = null + } + } + } + @JvmStatic fun startCallForegroundServiceNative() { val activity = checkNotNull(instance) { "MainActivity is unavailable" } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7fcfe176c5..1db10918d6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -444,6 +444,8 @@ pub fn run() { #[cfg(target_os = "android")] mobile::set_navigation_bar_color, #[cfg(target_os = "android")] + mobile::set_immersive_mode, + #[cfg(target_os = "android")] mobile::start_call_foreground_service, #[cfg(target_os = "android")] mobile::stop_call_foreground_service, diff --git a/src-tauri/src/mobile.rs b/src-tauri/src/mobile.rs index fa2b42dad3..677f017d66 100644 --- a/src-tauri/src/mobile.rs +++ b/src-tauri/src/mobile.rs @@ -34,6 +34,23 @@ pub fn set_navigation_bar_color(color: u32) -> Result<(), String> { call_bar_color("setNavigationBarColorNative", color) } +#[tauri::command] +pub fn set_immersive_mode(enabled: bool) -> Result<(), String> { + let vm = JAVA_VM.get().ok_or("java vm not initialized")?; + let mut env = vm.attach_current_thread().map_err(|e| e.to_string())?; + let result = env.call_static_method( + "moe/sable/client/MainActivity", + "setImmersiveModeNative", + "(Z)V", + &[JValue::Bool(enabled as u8)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } + result.map_err(|e| e.to_string())?; + Ok(()) +} + #[tauri::command] pub fn start_call_foreground_service() -> Result<(), String> { call_static_method("startCallForegroundServiceNative") diff --git a/src/app/components/MobileSwipeDownModal.tsx b/src/app/components/MobileSwipeDownModal.tsx index 5b85948441..0b5126f81b 100644 --- a/src/app/components/MobileSwipeDownModal.tsx +++ b/src/app/components/MobileSwipeDownModal.tsx @@ -28,6 +28,7 @@ interface MobileSwipeDownModalProps { sheetClassName?: string; sheetStyle?: CSSProperties; keyboardAware?: boolean; + zIndex?: number; } type FocusTrapOptions = ComponentProps['focusTrapOptions']; @@ -102,6 +103,7 @@ export function MobileSwipeDownModal({ sheetClassName, sheetStyle, keyboardAware = false, + zIndex, }: MobileSwipeDownModalProps) { const sheetRef = useRef(null); const backdropTouchRef = useRef(false); @@ -359,7 +361,10 @@ export function MobileSwipeDownModal({ portalRef ? css.MessageMobileOptionsWrappedContained : '' }`} data-gestures="ignore" - style={closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined} + style={{ + ...(closing ? { opacity: 0, transition: 'opacity 100ms ease-out' } : undefined), + zIndex, + }} onClick={handleBackdropClick} // Touch events deliberately propagate: the drag binds them on `document`, and // stopping them here would starve it. `data-gestures="ignore"` is what keeps the diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index d29f6333e0..4ff08b7adc 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -75,6 +75,7 @@ type RenderMessageContentProps = { mEvent?: MatrixEvent; mx?: MatrixClient; room?: Room; + onOpenMedia?: (mEvent: MatrixEvent) => void; }; const getMediaType = (url: string) => { @@ -112,6 +113,7 @@ function RenderMessageContentInternal({ mEvent, mx, room, + onOpenMedia, }: RenderMessageContentProps) { const content = useMemo(() => getContent() as Record, [getContent]); @@ -395,6 +397,7 @@ function RenderMessageContentInternal({ renderImageContent={(imageProps) => ( onOpenMedia?.(mEvent) : undefined} autoPlay={mediaAutoLoad} renderImage={(p) => { if (isGif && !autoplayGifs && p.src) { diff --git a/src/app/components/ResponsiveMenu.tsx b/src/app/components/ResponsiveMenu.tsx index 449c3719ca..10d9b60bd3 100644 --- a/src/app/components/ResponsiveMenu.tsx +++ b/src/app/components/ResponsiveMenu.tsx @@ -28,8 +28,10 @@ type ResponsiveMenuProps = { arrowNavigation?: 'vertical' | 'both'; /** How the menu shows on mobile: a bottom sheet, or a centred dialog for * option pickers, which a sheet makes look like an action menu. */ - mobile?: 'sheet' | 'dialog'; + mobile?: 'sheet' | 'dialog' | 'inline-dialog'; surfaceColor?: string; + /** Raises a mobile sheet above a parent fullscreen overlay. */ + mobileZIndex?: number; }; function MenuDialog({ @@ -75,6 +77,7 @@ export function ResponsiveMenu({ arrowNavigation = 'vertical', mobile = 'sheet', surfaceColor, + mobileZIndex, }: ResponsiveMenuProps) { // Null outside a provider, where desktop is the safe assumption. const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; @@ -97,6 +100,44 @@ export function ResponsiveMenu({ }; if (isMobile) { + if (mobile === 'inline-dialog') { + return ( + <> + {children} + {anchor && ( + + event.stopPropagation()} + > + {menu} + + + )} + + ); + } + const sheetStyle: CSSProperties | undefined = surfaceColor ? { backgroundColor: surfaceColor } : undefined; @@ -110,7 +151,7 @@ export function ResponsiveMenu({ )} {anchor && mobile === 'sheet' && ( - + {() => ( { renderViewer(); - const download = screen.getByText('Download'); + const download = screen.getByRole('button', { name: 'Download' }); fireEvent.pointerDown(download, { pointerId: 1, pointerType: 'touch' }); fireEvent.pointerUp(download, { pointerId: 1, pointerType: 'touch' }); fireEvent.click(download); @@ -110,6 +110,26 @@ describe('ImageViewer', () => { screenMocks.isMobile = false; }); + it('uses compact controls on mobile', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Zoom In' })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + + expect(screen.getByText('Turn pixelation on')).toBeInTheDocument(); + expect(screen.queryByText('Zoom out')).not.toBeInTheDocument(); + expect(screen.queryByText('Zoom in')).not.toBeInTheDocument(); + expect(screen.queryByText('Save image')).not.toBeInTheDocument(); + } finally { + screenMocks.isMobile = false; + } + }); + it('shows an error toast when downloading media fails', async () => { const error = new Error('network unavailable'); downloadMedia.mockRejectedValue(error); @@ -124,14 +144,14 @@ describe('ImageViewer', () => { }); }); - it('shows the Android gallery action for trusted image media', () => { + it('does not duplicate the Android download action in the overflow menu', () => { mockPlatform('android'); renderViewer({ info: { mimetype: 'image/png' } }); fireEvent.contextMenu(screen.getByAltText('kitten.png')); - expect(screen.getByText('Save to Gallery')).toBeInTheDocument(); + expect(screen.queryByText('Save to Gallery')).not.toBeInTheDocument(); }); it('labels the primary action Save to Photos on iOS without duplicating it in the overflow menu', () => { diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 1b47901c11..e1ba66dfd6 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,14 +1,30 @@ import type { MouseEventHandler } from 'react'; import { useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; -import { Box, Chip, Header, IconButton, Menu, MenuItem, Text, as, config, toRem } from 'folds'; +import { + Box, + Chip, + Header, + IconButton, + Menu, + MenuItem, + Text, + as, + config, + toRem, + type RectCords, +} from 'folds'; import { ArrowLeft, + CaretLeft, + CaretRight, ArrowsClockwise, Download, + DotsThree, Image, Minus, Plus, + ShareNetwork, menuIcon, phosphorSizeRem, sizedIcon, @@ -23,11 +39,12 @@ import { showToast } from '$state/toast'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; import type { IImageInfo } from '$types/matrix/common'; -import { CheckerboardIcon, CopyIcon, DownloadIcon } from '@phosphor-icons/react'; +import { CheckerboardIcon, CopyIcon } from '@phosphor-icons/react'; import { copyImageToClipboard } from '$utils/dom'; import { getDownloadFilename, saveFileToDevice, saveMediaToGallery } from '$utils/download'; import { ResponsiveMenu } from '$components/ResponsiveMenu'; import { isAndroidTauri, iosApp } from '$utils/platform'; +import { invoke } from '@tauri-apps/api/core'; import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; @@ -37,14 +54,24 @@ type ImageViewerProps = { src: string; requestClose: () => void; info?: IImageInfo; + onPrevious?: () => void; + onNext?: () => void; }; export const ImageViewer = as<'div', ImageViewerProps>( - ({ className, alt, filename, src, requestClose, info, ...props }, ref) => { + ({ className, alt, filename, src, requestClose, info, onPrevious, onNext, ...props }, ref) => { const zoomInputRef = useRef(null); const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; + useEffect(() => { + if (!isMobile || !isAndroidTauri()) return undefined; + void invoke('set_immersive_mode', { enabled: true }); + return () => { + void invoke('set_immersive_mode', { enabled: false }); + }; + }, [isMobile]); + // Android back closes the viewer instead of navigating away. useDismissOnBack(requestClose); @@ -70,16 +97,23 @@ export const ImageViewer = as<'div', ImageViewerProps>( handleImageLoad, handleImageDimensions, enableResizeWithWindow, - } = useImageGestures(true, 0.2, 0.1); + } = useImageGestures( + true, + 0.2, + 0.1, + 500, + isMobile ? { onDismiss: requestClose, onPrevious, onNext } : undefined + ); useEffect(() => { setIsImageReady(false); enableResizeWithWindow(); setIsEditingZoom(false); setZoomInput('100'); + resetTransforms(); if (imageRef.current) { imageRef.current = null; } - }, [src, enableResizeWithWindow, imageRef]); + }, [src, enableResizeWithWindow, imageRef, resetTransforms]); // When not actively editing the zoom input, keep it in sync with the current zoom level. useEffect(() => { @@ -116,10 +150,16 @@ export const ImageViewer = as<'div', ImageViewerProps>( await saveFileToDevice(fileContent, downloadFilename); }; - const menu = useMenuAnchor(); - const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); + const menu = useMenuAnchor(); + const [mobileMenuAnchor, setMobileMenuAnchor] = useState(); const closeActivation = useMobileTapActivation(isMobile, requestClose); + const menuActivation = useMobileTapActivation(isMobile, (evt) => { + if (isMobile) { + const rect = evt.currentTarget.getBoundingClientRect(); + setMobileMenuAnchor({ x: rect.x, y: rect.y, width: rect.width, height: rect.height }); + } else menu.openAt(evt.currentTarget); + }); const pixelatedActivation = useMobileTapActivation(isMobile, () => setIsPixelated(!isPixelated) ); @@ -138,17 +178,27 @@ export const ImageViewer = as<'div', ImageViewerProps>( const downloadActivation = useMobileTapActivation(isMobile, () => { void handleDownload(); }); + const shareActivation = useMobileTapActivation(isMobile, () => { + if (!navigator.share) return; + void navigator.share({ title: filename ?? alt, url: src }).catch(() => undefined); + }); const copyImageActivation = useMobileTapActivation(isMobile, () => { menu.close(); void downloadMedia(src).then(copyImageToClipboard); }); - const saveImageActivation = useMobileTapActivation(isMobile, () => { + const pixelatedMenuActivation = useMobileTapActivation(isMobile, () => { + setIsPixelated(!isPixelated); + menu.close(); + }); + const originalSizeMenuActivation = useMobileTapActivation(isMobile, () => { + setZoom(1); menu.close(); - void handleDownload(); }); - const galleryActivation = useMobileTapActivation(isMobile, () => { + const resetZoomMenuActivation = useMobileTapActivation(isMobile, () => { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); menu.close(); - void saveMediaToGallery(src, downloadFilename, galleryMimeType!); }); const handleContextMenu: MouseEventHandler = (evt) => { @@ -161,13 +211,72 @@ export const ImageViewer = as<'div', ImageViewerProps>( return ( <> { + menu.close(); + setMobileMenuAnchor(undefined); + }} align="Start" offset={0} + mobileZIndex={2_147_483_647} + mobile="inline-dialog" menu={ - + + {isMobile && ( + + } + {...pixelatedMenuActivation} + > + + {isPixelated ? 'Turn anti-aliasing on' : 'Turn pixelation on'} + + + )} + {isMobile && fitRatio !== 1 && transforms.zoom !== 1 && ( + + + View original size + + + )} + {isMobile && + (transforms.zoom !== fitRatio || + transforms.pan.x !== 0 || + transforms.pan.y !== 0) && ( + + + Reset zoom + + + )} ( Copy image - - - Save image - - - {canSaveToGallery && ( - - - Save to Gallery - - - )} } @@ -214,148 +299,201 @@ export const ImageViewer = as<'div', ImageViewerProps>( {...props} ref={ref} > -
- - - {sizedIcon(ArrowLeft, '200')} - - - {alt} - - - - - - - - {sizedIcon(Image, '200')} - - - {sizedIcon(ArrowsClockwise, '200')} - - - {sizedIcon(Minus, '50')} - - - - {isEditingZoom ? ( - - { - setZoomInput(e.target.value); - }} - onBlur={() => { - const next = parseInt(zoomInput, 10); - if (!Number.isNaN(next)) { - setZoom(next / 100); - } - setIsEditingZoom(false); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - const next = parseInt(zoomInput, 10); - if (!Number.isNaN(next)) { - setZoom(next / 100); - } - setIsEditingZoom(false); - } - }} - /> - % - - ) : ( - `${Math.round(transforms.zoom * 100)}%` - )} - - - 1 ? 'Success' : 'SurfaceVariant'} - outlined={transforms.zoom > 1} - size="300" - radii="Pill" - {...zoomInActivation} - aria-label="Zoom In" - title="Zoom In" - > - {sizedIcon(Plus, '50')} - - - {iosSaveToPhotos ? 'Save to Photos' : 'Download'} - - +
+ {isMobile ? ( + <> + + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + + {sizedIcon(ShareNetwork, '200')} + + + {sizedIcon(Download, '200')} + + + {sizedIcon(DotsThree, '200')} + + + + ) : ( + <> + + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + + + + + {sizedIcon(Image, '200')} + + + {sizedIcon(ArrowsClockwise, '200')} + + + {sizedIcon(Minus, '50')} + + + + {isEditingZoom ? ( + + { + setZoomInput(e.target.value); + }} + onBlur={() => { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + const next = parseInt(zoomInput, 10); + if (!Number.isNaN(next)) { + setZoom(next / 100); + } + setIsEditingZoom(false); + } + }} + /> + % + + ) : ( + `${Math.round(transforms.zoom * 100)}%` + )} + + + 1 ? 'Success' : 'SurfaceVariant'} + outlined={transforms.zoom > 1} + size="300" + radii="Pill" + {...zoomInActivation} + aria-label="Zoom In" + title="Zoom In" + > + {sizedIcon(Plus, '50')} + + + {iosSaveToPhotos ? 'Save to Photos' : 'Download'} + + + + )}
( onTouchMove={menu.triggerProps.onTouchMove} onTouchCancel={menu.triggerProps.onTouchCancel} > + {isMobile && onPrevious && ( + + {sizedIcon(CaretLeft, '200')} + + )} + {isMobile && onNext && ( + + {sizedIcon(CaretRight, '200')} + + )} void; + selectEvent: (eventId: string) => void; +}; + +function ResolvedRoomMedia({ + item, + requestClose, + onPrevious, + onNext, +}: { + item: RoomMediaItem; + requestClose: () => void; + onPrevious?: () => void; + onNext?: () => void; +}) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const createObjectURL = useCreateObjectURL(); + const rawMediaUrl = useMemo( + () => (item.url.startsWith('http') ? item.url : mxcUrlToHttp(mx, item.url, useAuthentication)), + [item.url, mx, useAuthentication] + ); + const resolvedMediaUrl = useRenderableMediaUrl( + item.encInfo ? undefined : (rawMediaUrl ?? undefined) + ); + const [source, loadSource] = useAsyncCallback( + useCallback(async () => { + if (item.encInfo) { + if (!rawMediaUrl) throw new Error('Invalid media URL'); + if (isTauri()) { + await setMediaEncryption(rawMediaUrl, item.encInfo, item.mimeType ?? FALLBACK_MIMETYPE); + return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; + } + return createObjectURL( + downloadEncryptedMedia(rawMediaUrl, (buffer) => + decryptFile(buffer, item.mimeType ?? FALLBACK_MIMETYPE, item.encInfo!) + ) + ); + } + return resolvedMediaUrl ?? rawMediaUrl ?? item.url; + }, [createObjectURL, item.encInfo, item.mimeType, item.url, rawMediaUrl, resolvedMediaUrl]) + ); + + useEffect(() => { + void loadSource().catch(() => undefined); + }, [loadSource]); + + if (source.status !== AsyncStatus.Success) { + return ( + + + + ); + } + + return ( + + ); +} + +export function RoomMediaViewer({ + items, + selectedEventId, + requestClose, + selectEvent, +}: RoomMediaViewerProps) { + const selectedIndex = items.findIndex((item) => item.eventId === selectedEventId); + const item = items[selectedIndex]; + + if (!item) return null; + + return ( + + 0 ? () => selectEvent(items[selectedIndex - 1]!.eventId) : undefined + } + onNext={ + selectedIndex < items.length - 1 + ? () => selectEvent(items[selectedIndex + 1]!.eventId) + : undefined + } + /> + + ); +} diff --git a/src/app/components/message/content/ImageContent.test.tsx b/src/app/components/message/content/ImageContent.test.tsx index db41be4063..3871d58609 100644 --- a/src/app/components/message/content/ImageContent.test.tsx +++ b/src/app/components/message/content/ImageContent.test.tsx @@ -45,7 +45,7 @@ const imageContent = ( preview} - renderViewer={() =>
viewer
} + renderViewer={() => } /> ); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 941c1e7026..0f5f205189 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -108,6 +108,8 @@ export type ImageContentProps = { spoilerReason?: string; renderViewer: (props: RenderViewerProps) => ReactNode; renderImage: (props: RenderImageProps) => ReactNode; + /** Opens the room-scoped mobile viewer when this attachment belongs to a timeline. */ + onOpenViewer?: () => void; matrixThumbnailMaxEdge?: number; mediaLayout?: 'default' | 'contained'; containedStripMinPx?: number; @@ -129,6 +131,7 @@ export const ImageContent = as<'div', ImageContentProps>( spoilerReason, renderViewer, renderImage, + onOpenViewer, matrixThumbnailMaxEdge, mediaLayout = 'default', containedStripMinPx, @@ -233,7 +236,10 @@ export const ImageContent = as<'div', ImageContentProps>( if (srcState.status !== AsyncStatus.Idle) return; try { const src = await loadSrc(); - if (src !== undefined) setViewer(true); + if (src !== undefined) { + if (onOpenViewer) onOpenViewer(); + else setViewer(true); + } } catch { // The existing error state is handled by the async callback. } @@ -272,6 +278,16 @@ export const ImageContent = as<'div', ImageContentProps>( const fillPreviewSlotStyle = fillsSlot ? ({ width: '100%', height: '100%' } as const) : undefined; + const viewerContent = + srcState.status === AsyncStatus.Success + ? renderViewer({ + src: viewerFullSrc ?? srcState.data, + alt: body ?? '', + filename, + requestClose: () => setViewer(false), + info, + }) + : null; return ( ( }} > {srcState.status === AsyncStatus.Success && ( - setViewer(false)}> - evt.stopPropagation()} - > - {renderViewer({ - src: viewerFullSrc ?? srcState.data, - alt: body ?? '', - filename, - requestClose: () => setViewer(false), - info: info, - })} - + setViewer(false)} mobile="fullscreen"> + {isMobile ? ( + viewerContent + ) : ( + evt.stopPropagation()} + > + {viewerContent} + + )} )} {typeof blurHash === 'string' && !load && ( @@ -355,7 +369,8 @@ export const ImageContent = as<'div', ImageContentProps>( onLottieError: handleError, onClick: () => { setIsHovered(false); - setViewer(true); + if (onOpenViewer) onOpenViewer(); + else setViewer(true); }, tabIndex: 0, })} diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx index e1328b04d4..32587b0a90 100644 --- a/src/app/components/modal-overlay/ModalOverlay.tsx +++ b/src/app/components/modal-overlay/ModalOverlay.tsx @@ -57,6 +57,7 @@ export function ModalOverlay({ contentRef?.current ?? document.body, escapeDeactivates, onDeactivate: requestClose, }} @@ -64,7 +65,13 @@ export function ModalOverlay({
{children}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index efec8313b2..49d2bc66b9 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -13,7 +13,7 @@ import { import type { Editor } from 'slate'; import { useAtomValue, useSetAtom, useStore } from 'jotai'; import type { Room, MatrixEvent, EventTimelineSet } from '$types/matrix-sdk'; -import { Direction, EventTimeline, EventType } from '$types/matrix-sdk'; +import { Direction, EventTimeline, EventType, MsgType } from '$types/matrix-sdk'; import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; @@ -23,6 +23,7 @@ import { ArrowDown, ChatTeardropDots, Checks, chipIcon } from '$components/icons import { MessageBase, CompactPlaceholder, DefaultPlaceholder } from '$components/message'; import { RoomIntro } from '$components/room-intro'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; import { useAlive } from '$hooks/useAlive'; import { useMessageEdit } from '$hooks/useMessageEdit'; import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange'; @@ -69,6 +70,9 @@ import { type ProcessedEvent, } from '$hooks/timeline/useProcessedTimeline'; import { useTimelineEventRenderer } from '$hooks/timeline/useTimelineEventRenderer'; +import { RoomMediaViewer } from '$components/image-viewer/RoomMediaViewer'; +import type { RoomMediaItem } from '$components/image-viewer/RoomMediaViewer'; +import type { IImageContent } from '$types/matrix/common'; import { useTimelineRendererContext } from '$hooks/timeline/useTimelineRendererContext'; import { TimelineScrollingProvider, useScrollActivity } from '$hooks/useTimelineScrollActivity'; import * as css from './RoomTimeline.css'; @@ -306,6 +310,27 @@ export type RoomTimelineProps = { onEditId?: (editId?: string) => void; }; +const getRoomMediaItem = (mEvent: MatrixEvent): RoomMediaItem | undefined => { + if (mEvent.isRedacted()) return undefined; + + const content = mEvent.getContent() as IImageContent; + const isImage = content.msgtype === MsgType.Image || mEvent.getType() === 'm.sticker'; + const url = content.file?.url ?? content.url; + const eventId = mEvent.getId(); + + if (!isImage || typeof url !== 'string' || !eventId) return undefined; + + return { + eventId, + body: content.body ?? content.filename ?? 'Image', + filename: content.filename, + url, + info: content.info, + mimeType: content.info?.mimetype, + encInfo: content.file, + }; +}; + export function RoomTimeline({ room, eventId, @@ -316,6 +341,7 @@ export function RoomTimeline({ onEditId: propsOnEditId, }: Readonly) { const mx = useMatrixClient(); + const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; const alive = useAlive(); const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId); @@ -926,6 +952,25 @@ export function RoomTimeline({ }, }); + const [selectedMediaEventId, setSelectedMediaEventId] = useState(); + const roomMedia = useMemo( + () => + timelineSync.timeline.linkedTimelines.flatMap((timeline) => + timeline.getEvents().flatMap((mEvent) => { + const item = getRoomMediaItem(mEvent); + return item ? [item] : []; + }) + ), + [timelineSync.timeline.linkedTimelines] + ); + const openRoomMedia = useCallback( + (mEvent: MatrixEvent) => { + if (isMobile && getRoomMediaItem(mEvent)) + setSelectedMediaEventId(mEvent.getId() ?? undefined); + }, + [isMobile] + ); + const renderMatrixEvent = useTimelineEventRenderer({ room, mx, @@ -951,6 +996,7 @@ export function RoomTimeline({ onDeleteFailedSend: actions.handleDeleteFailedSend, setOpenThread: actions.setOpenThread, handleOpenReply: actions.handleOpenReply, + onOpenMedia: openRoomMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }); @@ -1265,6 +1311,14 @@ export function RoomTimeline({ + {selectedMediaEventId && ( + setSelectedMediaEventId(undefined)} + /> + )} {showBackPaginationSpinner && ( diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 9f63156efd..0a6d69fc0b 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -341,6 +341,7 @@ export interface TimelineEventRendererOptions { onDeleteFailedSend: (mEvent: MatrixEvent) => void; setOpenThread: (threadId: string | undefined) => void; handleOpenReply: MouseEventHandler; + onOpenMedia?: (mEvent: MatrixEvent) => void; }; utils: { htmlReactParserOptions: HTMLReactParserOptions; @@ -389,6 +390,7 @@ export function useTimelineEventRenderer({ onDeleteFailedSend, setOpenThread, handleOpenReply, + onOpenMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }: TimelineEventRendererOptions) { @@ -751,6 +753,7 @@ export function useTimelineEventRenderer({ outlineAttachment={messageLayout === MessageLayout.Bubble} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> )} @@ -854,6 +857,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent)} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -906,6 +910,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> ); } @@ -1002,6 +1007,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent)} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -1162,6 +1168,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> )} diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index 9185ca9d5f..6efc5bfbc0 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -6,6 +6,12 @@ interface Vector2 { y: number; } +type FittedSwipeOptions = { + onDismiss?: () => void; + onPrevious?: () => void; + onNext?: () => void; +}; + // calculate pointer position relative to the image center // // use container rect & manually apply transforms as if we get two+ events quickly, @@ -21,7 +27,13 @@ function getCursorOffsetFromImageCenter( }; } -export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 500) => { +export const useImageGestures = ( + active: boolean, + step = 0.2, + min = 0.1, + max = 500, + fittedSwipeOptions?: FittedSwipeOptions +) => { const [transforms, setTransforms] = useState({ zoom: 1, pan: { x: 0, y: 0 }, @@ -52,6 +64,11 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const activePointers = useRef(new Map()); const initialDist = useRef(0); const lastTapRef = useRef(0); + const fittedSwipeRef = useRef<{ + startX: number; + startY: number; + direction?: 'horizontal' | 'vertical'; + }>(); const prepareForTransform = useCallback(() => { const img = imageRef.current; @@ -153,15 +170,19 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 lastTapRef.current = now; activePointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (activePointers.current.size === 1 && Math.abs(transforms.zoom - fitRatio) < 0.01) { + fittedSwipeRef.current = { startX: e.clientX, startY: e.clientY }; + } setCursor('grabbing'); // Initialize pinch zoom if (activePointers.current.size === 2) { + fittedSwipeRef.current = undefined; const points = Array.from(activePointers.current.values()); initialDist.current = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y); } }, - [active, disableResizeWithWindow, prepareForTransform] + [active, disableResizeWithWindow, fitRatio, prepareForTransform, transforms.zoom] ); const handlePointerMove = useCallback( @@ -188,6 +209,15 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 // Pan if (activePointers.current.size === 1) { + const fittedSwipe = fittedSwipeRef.current; + if (fittedSwipe) { + const deltaX = e.clientX - fittedSwipe.startX; + const deltaY = e.clientY - fittedSwipe.startY; + if (!fittedSwipe.direction && Math.max(Math.abs(deltaX), Math.abs(deltaY)) > 12) { + fittedSwipe.direction = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'; + } + return; + } setPan((p) => ({ x: p.x + e.movementX, y: p.y + e.movementY, @@ -199,7 +229,18 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 const handlePointerUp = useCallback( (e: PointerEvent) => { + const fittedSwipe = fittedSwipeRef.current; + if (fittedSwipe && activePointers.current.size === 1) { + const deltaX = e.clientX - fittedSwipe.startX; + const deltaY = e.clientY - fittedSwipe.startY; + if (fittedSwipe.direction === 'vertical' && deltaY > 96) fittedSwipeOptions?.onDismiss?.(); + if (fittedSwipe.direction === 'horizontal' && Math.abs(deltaX) > 72) { + if (deltaX > 0) fittedSwipeOptions?.onPrevious?.(); + else fittedSwipeOptions?.onNext?.(); + } + } activePointers.current.delete(e.pointerId); + if (activePointers.current.size === 0) fittedSwipeRef.current = undefined; if (activePointers.current.size === 0) { setCursor(active ? 'grab' : 'initial'); } @@ -207,7 +248,7 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 initialDist.current = 0; } }, - [active] + [active, fittedSwipeOptions] ); useEffect(() => { diff --git a/src/app/hooks/useMobileTapActivation.test.tsx b/src/app/hooks/useMobileTapActivation.test.tsx new file mode 100644 index 0000000000..e1a16d4c3d --- /dev/null +++ b/src/app/hooks/useMobileTapActivation.test.tsx @@ -0,0 +1,49 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { useMobileTapActivation } from './useMobileTapActivation'; + +function TapCloseHarness({ onUnderlyingClick }: { onUnderlyingClick: () => void }) { + const [open, setOpen] = useState(true); + const activation = useMobileTapActivation(true, () => setOpen(false)); + + return ( + <> + {open && ( + + )} + + + ); +} + +describe('useMobileTapActivation', () => { + it('swallows a synthetic click retargeted after activation unmounts its control', () => { + const onUnderlyingClick = vi.fn<() => void>(); + render(); + + const close = screen.getByTestId('close'); + fireEvent.pointerDown(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerUp(close, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.click(screen.getByTestId('underlying'), { clientX: 10, clientY: 10 }); + + expect(screen.queryByTestId('close')).not.toBeInTheDocument(); + expect(onUnderlyingClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/hooks/useMobileTapActivation.ts b/src/app/hooks/useMobileTapActivation.ts index 7e9ec88d71..f6a30835c5 100644 --- a/src/app/hooks/useMobileTapActivation.ts +++ b/src/app/hooks/useMobileTapActivation.ts @@ -3,6 +3,35 @@ import { useCallback, useRef } from 'react'; const TAP_MOVEMENT_THRESHOLD = 10; const MAX_TAP_DURATION = 500; +const SYNTHETIC_CLICK_TIMEOUT = 700; + +let clearPendingSyntheticClick: (() => void) | undefined; + +function blockSyntheticClick(clientX: number, clientY: number) { + clearPendingSyntheticClick?.(); + + let timeout: number | undefined; + const clear = () => { + document.removeEventListener('click', handleClick, true); + window.clearTimeout(timeout); + if (clearPendingSyntheticClick === clear) clearPendingSyntheticClick = undefined; + }; + const handleClick = (event: MouseEvent) => { + if ( + Math.abs(event.clientX - clientX) > TAP_MOVEMENT_THRESHOLD || + Math.abs(event.clientY - clientY) > TAP_MOVEMENT_THRESHOLD + ) { + return; + } + clear(); + event.preventDefault(); + event.stopImmediatePropagation(); + }; + + clearPendingSyntheticClick = clear; + document.addEventListener('click', handleClick, true); + timeout = window.setTimeout(clear, SYNTHETIC_CLICK_TIMEOUT); +} // Android WebView suppresses click synthesis after a drag gesture, so the first // tap on a nav control after swiping the drawer produces no click event. Activate @@ -87,6 +116,7 @@ export function useMobileTapActivation( evt.preventDefault(); activatedRef.current = true; + blockSyntheticClick(evt.clientX, evt.clientY); onActivateRef.current(evt); }; const onPointerCancel: PointerEventHandler = (evt) => {