diff --git a/.changeset/mobile-room-media-viewer.md b/.changeset/mobile-room-media-viewer.md new file mode 100644 index 000000000..ba6693257 --- /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 c5b3096f1..21cc16c7b 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,8 @@ class MainActivity : TauriActivity() { companion object { private var instance: MainActivity? = null + private var immersiveSystemBarsBehavior: Int? = null + private var immersiveDepth = 0 // 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 +180,32 @@ class MainActivity : TauriActivity() { } } + @JvmStatic + fun setImmersiveModeNative(enabled: Boolean) { + val activity = instance ?: return + activity.runOnUiThread { + val controller = WindowCompat.getInsetsController(activity.window, activity.window.decorView) + // Overlapping viewers each request immersive mode; the bars only come back + // once the last one has released it. + if (enabled) { + immersiveDepth += 1 + if (immersiveDepth > 1) return@runOnUiThread + if (immersiveSystemBarsBehavior == null) { + immersiveSystemBarsBehavior = controller.systemBarsBehavior + } + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + controller.hide(WindowInsetsCompat.Type.systemBars()) + } else { + immersiveDepth = maxOf(0, immersiveDepth - 1) + if (immersiveDepth > 0) return@runOnUiThread + 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 7fcfe176c..1db10918d 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 fa2b42dad..677f017d6 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 5b8594844..0b5126f81 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 d29f6333e..22e9587a8 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) => boolean; }; 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) ?? false : 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 449c3719c..51b1620d2 100644 --- a/src/app/components/ResponsiveMenu.tsx +++ b/src/app/components/ResponsiveMenu.tsx @@ -1,4 +1,5 @@ import type { ComponentProps, CSSProperties, ReactNode } from 'react'; +import { useEffect, useState } from 'react'; import type { RectCords } from 'folds'; import { Box, Overlay, OverlayBackdrop, OverlayCenter, PopOut } from 'folds'; import FocusTrap from 'focus-trap-react'; @@ -28,8 +29,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({ @@ -57,6 +60,68 @@ function MenuDialog({ ); } +function InlineMenuDialog({ + anchor, + requestClose, + focusTrapOptions, + zIndex, + children, +}: { + anchor: RectCords; + requestClose: () => void; + focusTrapOptions: FocusTrapOptions; + zIndex: number; + children: ReactNode; +}) { + // Android back closes the menu instead of navigating away. + useDismissOnBack(requestClose); + + const [viewport, setViewport] = useState(() => ({ + width: window.innerWidth, + height: window.innerHeight, + })); + useEffect(() => { + const handleResize = () => + setViewport({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const maxHeight = Math.round(viewport.height * 0.75); + + return ( + + + event.stopPropagation()} + > + {children} + + + + ); +} + /** * A menu that hangs off its trigger on desktop and rises as a bottom sheet on * mobile, where a popout anchored to a tiny target is hard to hit and easy to @@ -75,6 +140,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 +163,24 @@ export function ResponsiveMenu({ }; if (isMobile) { + if (mobile === 'inline-dialog') { + return ( + <> + {children} + {anchor && ( + + {menu} + + )} + + ); + } + const sheetStyle: CSSProperties | undefined = surfaceColor ? { backgroundColor: surfaceColor } : undefined; @@ -110,7 +194,11 @@ 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,51 @@ 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('closes the mobile overflow menu once an item is picked', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + fireEvent.click(screen.getByRole('button', { name: 'More options' })); + fireEvent.click(screen.getByText('Turn pixelation on')); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + } finally { + screenMocks.isMobile = false; + } + }); + + it('hides the share control when the platform cannot share', () => { + screenMocks.isMobile = true; + try { + renderViewer(); + + expect(screen.queryByRole('button', { name: 'Share' })).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); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 1b47901c1..9b40e8de2 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 { useCallback, 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, ImagesIcon } 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 { setImmersiveMode } from '$generated/tauri/commands'; 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 setImmersiveMode({ enabled: true }); + return () => { + void setImmersiveMode({ 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(() => { @@ -99,6 +133,8 @@ export const ImageViewer = as<'div', ImageViewerProps>( // On iOS the primary action saves trusted images straight to Photos (PhotoKit). const iosSaveToPhotos = iosApp() && (galleryMimeType?.startsWith('image/') ?? false); const downloadFilename = getDownloadFilename(filename, alt, 'image'); + // Android's primary action writes to Downloads; the gallery is a separate destination. + const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); const handleDownload = async () => { if (iosSaveToPhotos) { @@ -116,10 +152,21 @@ 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(); + // Mobile anchors the menu itself, so closing has to clear both anchors. + const closeMenu = useCallback(() => { + menu.close(); + setMobileMenuAnchor(undefined); + }, [menu]); 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,18 +185,50 @@ export const ImageViewer = as<'div', ImageViewerProps>( const downloadActivation = useMobileTapActivation(isMobile, () => { void handleDownload(); }); + // Web Share needs the decrypted bytes: `src` is a blob or Tauri asset URL that + // means nothing to the receiving app. + const canShare = isMobile && typeof navigator.share === 'function'; + const shareActivation = useMobileTapActivation(isMobile, () => { + void (async () => { + try { + const blob = await downloadMedia(src); + const file = new File([blob], downloadFilename, { + type: blob.type || galleryMimeType || 'application/octet-stream', + }); + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], title: filename ?? alt }); + return; + } + await navigator.share({ title: filename ?? alt, text: filename ?? alt }); + } catch { + // Cancelling the share sheet rejects; nothing to report. + } + })(); + }); const copyImageActivation = useMobileTapActivation(isMobile, () => { - menu.close(); + closeMenu(); void downloadMedia(src).then(copyImageToClipboard); }); - const saveImageActivation = useMobileTapActivation(isMobile, () => { - menu.close(); - void handleDownload(); + const pixelatedMenuActivation = useMobileTapActivation(isMobile, () => { + setIsPixelated(!isPixelated); + closeMenu(); + }); + const originalSizeMenuActivation = useMobileTapActivation(isMobile, () => { + setZoom(1); + closeMenu(); }); + const previousActivation = useMobileTapActivation(isMobile, () => onPrevious?.()); + const nextActivation = useMobileTapActivation(isMobile, () => onNext?.()); const galleryActivation = useMobileTapActivation(isMobile, () => { - menu.close(); + closeMenu(); void saveMediaToGallery(src, downloadFilename, galleryMimeType!); }); + const resetZoomMenuActivation = useMobileTapActivation(isMobile, () => { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); + closeMenu(); + }); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed) return; @@ -161,13 +240,69 @@ export const ImageViewer = as<'div', ImageViewerProps>( return ( <> + + {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 && ( @@ -214,148 +338,203 @@ 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)}%` +
+ {isMobile ? ( + <> + + + {sizedIcon(ArrowLeft, '200')} + + + {alt} + + + + {canShare && ( + + {sizedIcon(ShareNetwork, '200')} + )} - - - 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'} - - + + {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')} + + )} ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeContext: () => 'Mobile', + useScreenSizeOptionally: () => 'Mobile', +})); + +vi.mock('@tauri-apps/api/core', () => ({ + isTauri: () => false, + invoke: vi.fn<() => Promise>(), +})); + +vi.mock('$utils/matrix', () => ({ + mxcUrlToHttp: (_mx: unknown, url: string) => `https://hs.example/${url}`, + rewriteAuthenticatedMediaUrl: (url: string | null) => url, + downloadEncryptedMedia: vi.fn<() => Promise>(), + decryptFile: vi.fn<() => Promise>(), + downloadMedia: vi.fn<() => Promise>(), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ useMatrixClient: () => ({}) })); +vi.mock('$hooks/useMediaAuthentication', () => ({ useMediaAuthentication: () => false })); +vi.mock('$hooks/useRenderableMediaUrl', () => ({ + useRenderableMediaUrl: (url: string | undefined) => url, +})); +const createObjectURL = (value: string) => Promise.resolve(value); +vi.mock('$hooks/useObjectURL', () => ({ useCreateObjectURL: () => createObjectURL })); + +const items: RoomMediaItem[] = [ + { eventId: '$one', body: 'first.png', url: 'mxc://example.org/one' }, + { eventId: '$two', body: 'second.png', url: 'mxc://example.org/two' }, +]; + +const renderViewer = (selectedEventId: string, selectEvent = vi.fn<(id: string) => void>()) => { + render( + void>()} + selectEvent={selectEvent} + /> + ); + return selectEvent; +}; + +describe('RoomMediaViewer', () => { + it('renders the viewer for the selected item', async () => { + renderViewer('$one'); + + expect(await screen.findByAltText('first.png')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + it('offers next but not previous on the first item', async () => { + renderViewer('$one'); + + await screen.findByAltText('first.png'); + expect(screen.getByRole('button', { name: 'Next image' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Previous image' })).not.toBeInTheDocument(); + }); + + it('selects the following event when Next is tapped', async () => { + const selectEvent = renderViewer('$one'); + + await screen.findByAltText('first.png'); + fireEvent.click(screen.getByRole('button', { name: 'Next image' })); + + await waitFor(() => expect(selectEvent).toHaveBeenCalledWith('$two')); + }); + + it('closes when the selected event is no longer in the gallery', async () => { + const requestClose = vi.fn<() => void>(); + render( + void>()} + /> + ); + + await waitFor(() => expect(requestClose).toHaveBeenCalled()); + }); +}); diff --git a/src/app/components/image-viewer/RoomMediaViewer.tsx b/src/app/components/image-viewer/RoomMediaViewer.tsx new file mode 100644 index 000000000..ebb99c6fc --- /dev/null +++ b/src/app/components/image-viewer/RoomMediaViewer.tsx @@ -0,0 +1,158 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Spinner } from 'folds'; +import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; +import { useCreateObjectURL } from '$hooks/useObjectURL'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; +import type { IImageInfo } from '$types/matrix/common'; +import { + decryptFile, + downloadEncryptedMedia, + mxcUrlToHttp, + rewriteAuthenticatedMediaUrl, +} from '$utils/matrix'; +import { FALLBACK_MIMETYPE } from '$utils/mimeTypes'; +import { setMediaEncryption } from '$utils/tauriMediaEncryption'; +import { isTauri } from '@tauri-apps/api/core'; +import { ImageViewer } from './ImageViewer'; + +export type RoomMediaItem = { + eventId: string; + body: string; + filename?: string; + url: string; + info?: IImageInfo; + mimeType?: string; + encInfo?: EncryptedAttachmentInfo; +}; + +type RoomMediaViewerProps = { + items: RoomMediaItem[]; + selectedEventId: string; + requestClose: () => void; + selectEvent: (eventId: string) => void; +}; + +type ResolvedMedia = { item: RoomMediaItem; src: string }; + +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) + ); + + // The previously resolved image stays mounted while the next one loads, so that + // stepping through the gallery does not tear the viewer — and with it the Android + // immersive mode — down and back up between every image. + const [resolved, setResolved] = useState(); + const requestRef = useRef(0); + + useEffect(() => { + requestRef.current += 1; + const request = requestRef.current; + const resolve = async () => { + const { encInfo, mimeType } = item; + if (encInfo) { + if (!rawMediaUrl) throw new Error('Invalid media URL'); + if (isTauri()) { + await setMediaEncryption(rawMediaUrl, encInfo, mimeType ?? FALLBACK_MIMETYPE); + return rewriteAuthenticatedMediaUrl(rawMediaUrl)!; + } + return createObjectURL( + downloadEncryptedMedia(rawMediaUrl, (buffer) => + decryptFile(buffer, mimeType ?? FALLBACK_MIMETYPE, encInfo) + ) + ); + } + return resolvedMediaUrl ?? rawMediaUrl ?? item.url; + }; + + resolve() + .then((src) => { + if (requestRef.current !== request) return; + // Bail on an unchanged result: re-setting it would re-run this effect. + setResolved((prev) => (prev?.item === item && prev.src === src ? prev : { item, src })); + }) + .catch(() => undefined); + }, [item, rawMediaUrl, resolvedMediaUrl, createObjectURL]); + + const loading = resolved?.item.eventId !== item.eventId; + + return ( + <> + {resolved && ( + + )} + {loading && ( + + + + )} + + ); +} + +export function RoomMediaViewer({ + items, + selectedEventId, + requestClose, + selectEvent, +}: RoomMediaViewerProps) { + const selectedIndex = items.findIndex((mediaItem) => mediaItem.eventId === selectedEventId); + const item = items[selectedIndex]; + + // The selected event can vanish under us, most often through a redaction. + useEffect(() => { + if (!item) requestClose(); + }, [item, requestClose]); + + 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 db41be406..2b5fe6303 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={() => } /> ); @@ -92,6 +92,41 @@ describe('ImageContent', () => { expect(screen.getByAltText('preview').closest('[data-gestures="ignore"]')).not.toBeNull(); }); + it('falls back to its own viewer when the room gallery declines to open', async () => { + const onOpenViewer = vi.fn<() => boolean>(() => false); + render( + preview} + renderViewer={() => } + onOpenViewer={onOpenViewer} + /> + ); + + // A plain click, so this case does not arm the shared synthetic-click blocker. + fireEvent.click(screen.getByRole('button', { name: 'View' })); + + await waitFor(() => expect(screen.getByText('viewer')).toBeInTheDocument()); + expect(onOpenViewer).toHaveBeenCalled(); + }); + + it('leaves the local viewer closed when the room gallery takes over', async () => { + const onOpenViewer = vi.fn<() => boolean>(() => true); + render( + preview} + renderViewer={() => } + onOpenViewer={onOpenViewer} + /> + ); + + fireEvent.click(screen.getByRole('button', { name: 'View' })); + + await waitFor(() => expect(onOpenViewer).toHaveBeenCalled()); + expect(screen.queryByText('viewer')).not.toBeInTheDocument(); + }); + it('does not mount hover controls for touch pointer entry', () => { render(imageContent); @@ -144,8 +179,15 @@ describe('ImageContent', () => { const initialSrc = srcs[srcs.length - 1]; expect(initialSrc).toBe(SABLE_MEDIA_URL); + // The mobile fullscreen viewer traps focus and blocks outside clicks, so + // close it before retrying. + fireEvent.keyDown(document.body, { key: 'Escape' }); fireEvent.error(img); - fireEvent.click(await screen.findByRole('button', { name: 'Retry' })); + // Press before clicking: the tap that opened the viewer armed the synthetic + // click blocker, and a real press is what disarms it. + const retry = await screen.findByRole('button', { name: 'Retry' }); + fireEvent.pointerDown(retry, { pointerId: 2, pointerType: 'mouse', isPrimary: true }); + fireEvent.click(retry); await waitFor(() => { const retriedSrc = srcs[srcs.length - 1] ?? ''; diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 941c1e702..3fb36e788 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -108,6 +108,9 @@ 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. + * Returns false when it declines, and the local viewer opens instead. */ + onOpenViewer?: () => boolean; matrixThumbnailMaxEdge?: number; mediaLayout?: 'default' | 'contained'; containedStripMinPx?: number; @@ -129,6 +132,7 @@ export const ImageContent = as<'div', ImageContentProps>( spoilerReason, renderViewer, renderImage, + onOpenViewer, matrixThumbnailMaxEdge, mediaLayout = 'default', containedStripMinPx, @@ -233,7 +237,7 @@ 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 && !onOpenViewer?.()) setViewer(true); } catch { // The existing error state is handled by the async callback. } @@ -272,6 +276,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" + background="#000" + > + {isMobile ? ( + viewerContent + ) : ( + evt.stopPropagation()} + > + {viewerContent} + + )} )} {typeof blurHash === 'string' && !load && ( @@ -355,7 +372,7 @@ export const ImageContent = as<'div', ImageContentProps>( onLottieError: handleError, onClick: () => { setIsHovered(false); - setViewer(true); + if (!onOpenViewer?.()) setViewer(true); }, tabIndex: 0, })} diff --git a/src/app/components/modal-overlay/ModalOverlay.tsx b/src/app/components/modal-overlay/ModalOverlay.tsx index e1328b04d..1cd72370e 100644 --- a/src/app/components/modal-overlay/ModalOverlay.tsx +++ b/src/app/components/modal-overlay/ModalOverlay.tsx @@ -27,6 +27,8 @@ type ModalOverlayProps = { contentRef?: MutableRefObject; /** Set false for flows that Escape must not abort, such as device verification. */ escapeDeactivates?: FocusTrapOptions['escapeDeactivates']; + /** Fills the mobile fullscreen wrapper, for content that does not paint its own. */ + background?: string; children: ReactNode; }; @@ -38,6 +40,7 @@ export function ModalOverlay({ size, contentRef, escapeDeactivates = stopPropagation, + background, children, }: ModalOverlayProps) { // Null outside a provider, where desktop is the safe assumption. @@ -57,6 +60,7 @@ export function ModalOverlay({ contentRef?.current ?? document.body, escapeDeactivates, onDeactivate: requestClose, }} @@ -64,7 +68,13 @@ export function ModalOverlay({
{children}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 378d2fb73..37f0369f0 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, RoomEvent } from '$types/matrix-sdk'; +import { Direction, EventTimeline, EventType, MsgType, RoomEvent } from '$types/matrix-sdk'; import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; @@ -24,6 +24,7 @@ import { MessageBase, CompactPlaceholder, DefaultPlaceholder } from '$components import { RoomIntro } from '$components/room-intro'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useMatrixEvent } from '$hooks/useMatrixEvent'; +import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; import { useAlive } from '$hooks/useAlive'; import { useMessageEdit } from '$hooks/useMessageEdit'; import { useDocumentFocusChange } from '$hooks/useDocumentFocusChange'; @@ -70,6 +71,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'; @@ -312,6 +316,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, @@ -322,6 +347,7 @@ export function RoomTimeline({ onEditId: propsOnEditId, }: Readonly) { const mx = useMatrixClient(); + const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; const alive = useAlive(); const roomSyncLoading = useSlidingSyncRoomLoading(room.roomId); @@ -936,6 +962,30 @@ export function RoomTimeline({ // the row memo has to compare them itself to notice a power-level change. const rowPermissions = { canRedact, canDeleteOwn, canSendReaction, canPinEvent }; + const [selectedMediaEventId, setSelectedMediaEventId] = useState(); + const [roomMedia, setRoomMedia] = useState([]); + // Returns false on desktop and for anything the gallery cannot show, so the + // attachment falls back to its own viewer instead of doing nothing. The gallery + // is collected on open: events are appended into the linked timelines in place, + // so there is no identity change a memo could key off. + const openRoomMedia = useCallback( + (mEvent: MatrixEvent) => { + const mediaEventId = mEvent.getId(); + if (!isMobile || !mediaEventId || !getRoomMediaItem(mEvent)) return false; + setRoomMedia( + timelineSyncRef.current.timeline.linkedTimelines.flatMap((timeline) => + timeline.getEvents().flatMap((timelineEvent) => { + const item = getRoomMediaItem(timelineEvent); + return item ? [item] : []; + }) + ) + ); + setSelectedMediaEventId(mediaEventId); + return true; + }, + [isMobile] + ); + const renderMatrixEvent = useTimelineEventRenderer({ room, mx, @@ -956,6 +1006,7 @@ export function RoomTimeline({ onDeleteFailedSend: actions.handleDeleteFailedSend, setOpenThread: actions.setOpenThread, handleOpenReply: actions.handleOpenReply, + onOpenMedia: openRoomMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }); @@ -1302,6 +1353,14 @@ export function RoomTimeline({ + {selectedMediaEventId && ( + setSelectedMediaEventId(undefined)} + /> + )} {showBackPaginationSpinner && ( diff --git a/src/app/generated/tauri/commands.ts b/src/app/generated/tauri/commands.ts index 1dd132bbb..6132f6140 100644 --- a/src/app/generated/tauri/commands.ts +++ b/src/app/generated/tauri/commands.ts @@ -74,6 +74,10 @@ export async function saveMediaToPhotos(params: types.SaveMediaToPhotosParams): return invoke('save_media_to_photos', params); } +export async function setImmersiveMode(params: types.SetImmersiveModeParams): Promise { + return invoke('set_immersive_mode', params); +} + export async function setMediaEncryption(params: types.SetMediaEncryptionParams): Promise { return invoke('set_media_encryption', params); } diff --git a/src/app/generated/tauri/types.ts b/src/app/generated/tauri/types.ts index 5c41032af..b3fad783e 100644 --- a/src/app/generated/tauri/types.ts +++ b/src/app/generated/tauri/types.ts @@ -120,6 +120,11 @@ export interface SaveMediaToPhotosParams { [key: string]: unknown; } +export interface SetImmersiveModeParams { + enabled: boolean; + [key: string]: unknown; +} + export interface SetMediaEncryptionParams { url: string; key: string; diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 42c8f727f..8bf1cbc01 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -347,6 +347,7 @@ export interface TimelineEventRendererOptions { onDeleteFailedSend: (mEvent: MatrixEvent) => void; setOpenThread: (threadId: string | undefined) => void; handleOpenReply: MouseEventHandler; + onOpenMedia?: (mEvent: MatrixEvent) => boolean; }; utils: { htmlReactParserOptions: HTMLReactParserOptions; @@ -395,6 +396,7 @@ export function useTimelineEventRenderer({ onDeleteFailedSend, setOpenThread, handleOpenReply, + onOpenMedia, }, utils: { htmlReactParserOptions, linkifyOpts, getMemberPowerTag, parseMemberEvent }, }: TimelineEventRendererOptions) { @@ -757,6 +759,7 @@ export function useTimelineEventRenderer({ outlineAttachment={messageLayout === MessageLayout.Bubble} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> )} @@ -860,6 +863,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent) ?? false} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -912,6 +916,7 @@ export function useTimelineEventRenderer({ mEvent={mEvent} mx={mx} room={room} + onOpenMedia={onOpenMedia} /> ); } @@ -1008,6 +1013,7 @@ export function useTimelineEventRenderer({ renderImageContent={(props) => ( onOpenMedia?.(mEvent) ?? false} autoPlay={mediaAutoLoad} renderImage={(p) => { if (!autoplayStickers && p.src) { @@ -1168,6 +1174,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 9185ca9d5..07e84dae3 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,15 @@ 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'; + }>(); + // Callers pass a fresh options object every render; keeping it in a ref stops the + // window listeners below from being torn down and rebound on each one. + const fittedSwipeOptionsRef = useRef(fittedSwipeOptions); + fittedSwipeOptionsRef.current = fittedSwipeOptions; const prepareForTransform = useCallback(() => { const img = imageRef.current; @@ -153,15 +174,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 +213,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, @@ -197,10 +231,22 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 [setZoom, min, max, setPan] ); - const handlePointerUp = useCallback( - (e: PointerEvent) => { + const releasePointer = useCallback( + (e: PointerEvent, cancelled: boolean) => { + const fittedSwipe = fittedSwipeRef.current; + if (fittedSwipe && !cancelled && activePointers.current.size === 1) { + const deltaX = e.clientX - fittedSwipe.startX; + const deltaY = e.clientY - fittedSwipe.startY; + const options = fittedSwipeOptionsRef.current; + if (fittedSwipe.direction === 'vertical' && deltaY > 96) options?.onDismiss?.(); + if (fittedSwipe.direction === 'horizontal' && Math.abs(deltaX) > 72) { + if (deltaX > 0) options?.onPrevious?.(); + else options?.onNext?.(); + } + } activePointers.current.delete(e.pointerId); if (activePointers.current.size === 0) { + fittedSwipeRef.current = undefined; setCursor(active ? 'grab' : 'initial'); } if (activePointers.current.size < 2) { @@ -210,16 +256,25 @@ export const useImageGestures = (active: boolean, step = 0.2, min = 0.1, max = 5 [active] ); + const handlePointerUp = useCallback( + (e: PointerEvent) => releasePointer(e, false), + [releasePointer] + ); + const handlePointerCancel = useCallback( + (e: PointerEvent) => releasePointer(e, true), + [releasePointer] + ); + useEffect(() => { window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); - window.addEventListener('pointercancel', handlePointerUp); + window.addEventListener('pointercancel', handlePointerCancel); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); - window.removeEventListener('pointercancel', handlePointerUp); + window.removeEventListener('pointercancel', handlePointerCancel); }; - }, [handlePointerMove, handlePointerUp]); + }, [handlePointerMove, handlePointerUp, handlePointerCancel]); // When the size of the container changes, zoom without a transition. const handleContainerResize = useCallback( diff --git a/src/app/hooks/useMobileTapActivation.test.tsx b/src/app/hooks/useMobileTapActivation.test.tsx new file mode 100644 index 000000000..db849cd08 --- /dev/null +++ b/src/app/hooks/useMobileTapActivation.test.tsx @@ -0,0 +1,84 @@ +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(); + }); + + it('does not swallow a genuine second tap at the same spot', () => { + 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, + }); + + // Android synthesised no click, so the blocker is still armed. A fresh tap on + // the element underneath must still get through. + const underlying = screen.getByTestId('underlying'); + fireEvent.pointerDown(underlying, { + pointerId: 2, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.click(underlying, { clientX: 10, clientY: 10 }); + + expect(onUnderlyingClick).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/hooks/useMobileTapActivation.ts b/src/app/hooks/useMobileTapActivation.ts index 7e9ec88d7..8359d7adb 100644 --- a/src/app/hooks/useMobileTapActivation.ts +++ b/src/app/hooks/useMobileTapActivation.ts @@ -3,6 +3,40 @@ import { useCallback, useRef } from 'react'; const TAP_MOVEMENT_THRESHOLD = 10; const MAX_TAP_DURATION = 500; +// A synthesised click follows its touchend within a frame or two. The window only +// has to outlast the legacy 300ms tap delay, and a fresh pointerdown disarms it so +// a genuine second tap on the same spot is never swallowed. +const SYNTHETIC_CLICK_TIMEOUT = 350; + +let clearPendingSyntheticClick: (() => void) | undefined; + +function blockSyntheticClick(clientX: number, clientY: number) { + clearPendingSyntheticClick?.(); + + let timeout: number | undefined; + const clear = () => { + document.removeEventListener('click', handleClick, true); + document.removeEventListener('pointerdown', clear, 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); + document.addEventListener('pointerdown', clear, 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 +121,7 @@ export function useMobileTapActivation( evt.preventDefault(); activatedRef.current = true; + blockSyntheticClick(evt.clientX, evt.clientY); onActivateRef.current(evt); }; const onPointerCancel: PointerEventHandler = (evt) => {