diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d2ac364e1..4a8a3925c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -168,7 +168,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -179,7 +179,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1011,7 +1011,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" dependencies = [ "lazy_static", - "windows-sys 0.48.0", + "windows-sys 0.59.0", ] [[package]] @@ -1553,7 +1553,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1585,7 +1585,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1855,7 +1855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3653,7 +3653,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3777,7 +3777,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4026,6 +4026,19 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-photos" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6ea9a706f18e32cf8a0723e99855af4f135282891e07259301bae9135ae3872" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -4181,7 +4194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -4839,7 +4852,7 @@ dependencies = [ "once_cell", "socket2 0.6.5", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5258,7 +5271,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5316,7 +5329,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5355,6 +5368,7 @@ dependencies = [ "aes", "async-stream", "base64 0.22.1", + "block2", "cef", "ctr", "enigo", @@ -5366,6 +5380,7 @@ dependencies = [ "log", "objc2", "objc2-foundation", + "objc2-photos", "percent-encoding", "regex", "reqwest 0.12.28", @@ -6011,7 +6026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7030,7 +7045,7 @@ dependencies = [ "serde_with", "swift-rs", "thiserror 2.0.19", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.3+spec-1.1.0", "url", "urlpattern", "uuid", @@ -7069,7 +7084,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7694,7 +7709,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.19", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7768,7 +7783,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8398,7 +8413,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f7868c710..703f4d477 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -127,7 +127,19 @@ tauri-plugin-sharekit = { git = "https://github.com/Choochmeque/tauri-plugin-sha [target.'cfg(target_os = "ios")'.dependencies] objc2 = "0.6" -objc2-foundation = { version = "0.3", features = ["NSFileManager", "NSString", "NSURL"] } +objc2-foundation = { version = "0.3", features = ["NSError", "NSFileManager", "NSString", "NSURL"] } +objc2-photos = { version = "0.3.2", default-features = false, features = [ + "std", + "PHPhotoLibrary", + "PHChangeRequest", + "PHAssetChangeRequest", + "PHAssetCreationRequest", + "PhotosTypes", + "bitflags", + "block2", + "dispatch2", +] } +block2 = "0.6" tauri-plugin-fs = "2" [target.'cfg(target_os = "android")'.dependencies] diff --git a/src-tauri/capabilities/android.json b/src-tauri/capabilities/android.json index 51be0168d..fc3fa717e 100644 --- a/src-tauri/capabilities/android.json +++ b/src-tauri/capabilities/android.json @@ -6,6 +6,7 @@ "permissions": [ "android-fs:allow-check-public-files-permission", "android-fs:allow-create-new-public-file", + "android-fs:allow-create-new-public-image-file", "android-fs:allow-request-public-files-permission", "android-fs:allow-write-file", "android-fs:allow-set-public-file-pending", diff --git a/src-tauri/ios-project.yml b/src-tauri/ios-project.yml index 0209f360d..188ea436e 100644 --- a/src-tauri/ios-project.yml +++ b/src-tauri/ios-project.yml @@ -105,6 +105,7 @@ targets: CFBundleShortVersionString: {{apple.bundle-version-short}} CFBundleVersion: "{{apple.bundle-version}}" UIBackgroundModes: [audio] + NSPhotoLibraryAddUsageDescription: Sable saves images you download to your photo library. # Mirrors plugins.deep-link in tauri.conf.json: the plugin only patches # these in when cargo reruns its build script, so a fresh `tauri ios # init` silently loses them unless they live here. diff --git a/src-tauri/src/ios.rs b/src-tauri/src/ios.rs index b3160db09..71f19fa60 100644 --- a/src-tauri/src/ios.rs +++ b/src-tauri/src/ios.rs @@ -159,3 +159,139 @@ pub(crate) fn play_notification_sound(kind: String) -> Result<(), String> { unsafe { AudioServicesPlaySystemSound(sound_id) }; Ok(()) } + +// PhotoKit lane for saving images to the camera roll with add-only access; +// the prompt text is NSPhotoLibraryAddUsageDescription in ios-project.yml. + +// Empty block only to emit the linker directive, same role as AudioToolbox above. +#[link(name = "Photos", kind = "framework")] +extern "C" {} + +use std::ffi::OsStr; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; + +use block2::RcBlock; +use objc2_photos::{ + PHAccessLevel, PHAssetCreationRequest, PHAssetResourceType, PHAuthorizationStatus, + PHPhotoLibrary, +}; +use tokio::sync::oneshot; + +fn photo_access_allowed(status: PHAuthorizationStatus) -> bool { + // Add-only access: .limited still permits adds (it only restricts reads). + status == PHAuthorizationStatus::Authorized || status == PHAuthorizationStatus::Limited +} + +fn photo_authorization_error(status: PHAuthorizationStatus) -> String { + if status == PHAuthorizationStatus::Denied { + "permission denied: allow photo access in Settings > Apps > Sable > Photos".to_string() + } else if status == PHAuthorizationStatus::Restricted { + "saving to Photos is blocked by device management or parental controls".to_string() + } else { + format!("unexpected photo authorization status: {status:?}") + } +} + +async fn request_photo_add_authorization() -> Result<(), String> { + let status = + unsafe { PHPhotoLibrary::authorizationStatusForAccessLevel(PHAccessLevel::AddOnly) }; + if photo_access_allowed(status) { + return Ok(()); + } + if status != PHAuthorizationStatus::NotDetermined { + return Err(photo_authorization_error(status)); + } + + let (tx, rx) = oneshot::channel::(); + { + let tx = Mutex::new(Some(tx)); + let handler: RcBlock = RcBlock::new(move |status| { + if let Ok(mut guard) = tx.lock() { + if let Some(tx) = guard.take() { + let _ = tx.send(status); + } + } + }); + unsafe { + PHPhotoLibrary::requestAuthorizationForAccessLevel_handler( + PHAccessLevel::AddOnly, + &handler, + ); + } + // PhotoKit copies the handler; keep the !Send RcBlock out of the await below. + } + + let granted = rx + .await + .map_err(|_| "photo authorization request was cancelled".to_string())?; + if photo_access_allowed(granted) { + Ok(()) + } else { + Err(photo_authorization_error(granted)) + } +} + +fn write_media_to_photo_library(bytes: &[u8], filename: &str) -> Result<(), String> { + // PhotoKit infers the content type from the file URL, so the temp file + // keeps the extension; `file_name` strips any directory components. + static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); + let leaf = std::path::Path::new(filename) + .file_name() + .unwrap_or_else(|| OsStr::new("download")); + let mut path = std::env::temp_dir(); + path.push(format!( + "sable-photos-{}-{}-{}", + std::process::id(), + TEMP_COUNTER.fetch_add(1, Ordering::Relaxed), + leaf.to_string_lossy() + )); + + if let Err(error) = std::fs::write(&path, bytes) { + let _ = std::fs::remove_file(&path); + return Err(format!("failed to write {}: {error}", path.display())); + } + + let result = unsafe { + let path_str = NSString::from_str(&path.to_string_lossy()); + let url = NSURL::fileURLWithPath(&path_str); + let library = PHPhotoLibrary::sharedPhotoLibrary(); + // PhotoKit rejects creation requests instantiated outside the change block. + let change: RcBlock = RcBlock::new(move || { + let request = PHAssetCreationRequest::creationRequestForAsset(); + request.addResourceWithType_fileURL_options(PHAssetResourceType::Photo, &url, None); + }); + library + .performChangesAndWait_error(RcBlock::as_ptr(&change)) + .map_err(|error| { + format!( + "failed to save '{filename}' to Photos ({} error {}): {}", + error.domain(), + error.code(), + error.localizedDescription() + ) + }) + }; + let _ = std::fs::remove_file(&path); + result +} + +#[tauri::command] +pub async fn save_media_to_photos( + bytes: Vec, + filename: String, + mime_type: String, +) -> Result<(), String> { + if !mime_type.starts_with("image/") { + return Err(format!( + "unsupported media type '{mime_type}': only images can be saved to Photos" + )); + } + + request_photo_add_authorization().await?; + + // performChangesAndWait blocks until commit; it must not run on the main thread. + tauri::async_runtime::spawn_blocking(move || write_media_to_photo_library(&bytes, &filename)) + .await + .map_err(|error| format!("failed to run photo save: {error}"))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c715e31a8..fce113c15 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -446,6 +446,8 @@ pub fn run() { mobile::stop_call_foreground_service, #[cfg(target_os = "ios")] ios::haptic_feedback, + #[cfg(target_os = "ios")] + ios::save_media_to_photos, #[cfg(any(target_os = "android", target_os = "ios"))] play_notification_sound, #[cfg(desktop)] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index fddc1a7d8..7fb809737 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -24,7 +24,7 @@ }, "iOS": { "template": "src-tauri/ios-project.yml", - "frameworks": ["AudioToolbox"] + "frameworks": ["AudioToolbox", "Photos"] }, "linux": { "deb": { diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index 16fe88546..edae8fa11 100644 --- a/src/app/components/image-viewer/ImageViewer.test.tsx +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -3,8 +3,26 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import FileSaver from 'file-saver'; import { ImageViewer } from './ImageViewer'; +import { showToast } from '$state/toast'; +import type { IImageInfo } from '$types/matrix/common'; const downloadMedia = vi.fn<(src: string) => Promise>(); +const saveMediaToGallery = + vi.fn<(input: string, filename: string, mimeType: string) => Promise>(); +const toastMocks = vi.hoisted(() => ({ + showToast: vi.fn<(text: string, durationMs?: number) => void>(), +})); +vi.mock('$state/toast', () => ({ showToast: toastMocks.showToast })); +const platformMocks = vi.hoisted(() => ({ + isAndroidTauri: vi.fn<() => boolean>(() => false), + iosApp: vi.fn<() => boolean>(() => false), +})); +const screenMocks = vi.hoisted(() => ({ isMobile: false })); +vi.mock('$utils/platform', async (importOriginal) => ({ + ...(await importOriginal()), + isAndroidTauri: platformMocks.isAndroidTauri, + iosApp: platformMocks.iosApp, +})); const gestureMocks = vi.hoisted(() => ({ onPointerDown: vi.fn<(event: React.PointerEvent) => void>(), })); @@ -30,6 +48,10 @@ vi.mock('$hooks/useImageGestures', () => ({ vi.mock('$utils/matrix', () => ({ downloadMedia: (...args: [string]) => downloadMedia(...args), })); +vi.mock('$utils/download', async (importOriginal) => ({ + ...(await importOriginal()), + saveMediaToGallery: (...args: [string, string, string]) => saveMediaToGallery(...args), +})); vi.mock('file-saver', () => ({ default: { @@ -39,21 +61,30 @@ vi.mock('file-saver', () => ({ vi.mock('$hooks/useScreenSize', () => ({ ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, - useScreenSizeContext: () => 'Desktop', - useScreenSizeOptionally: () => 'Desktop', + useScreenSizeContext: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'), + useScreenSizeOptionally: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'), })); +const renderViewer = (props: { alt?: string; src?: string; info?: IImageInfo } = {}) => + render( + void>()} + {...props} + /> + ); + +const mockPlatform = (platform: 'web' | 'android' | 'ios') => { + platformMocks.isAndroidTauri.mockReturnValue(platform === 'android'); + platformMocks.iosApp.mockReturnValue(platform === 'ios'); +}; + describe('ImageViewer', () => { it('downloads media without passing a media token argument', async () => { downloadMedia.mockResolvedValue(new Blob(['image'])); - render( - void>()} - /> - ); + renderViewer(); fireEvent.click(screen.getByText('Download')); @@ -62,6 +93,98 @@ describe('ImageViewer', () => { }); expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'kitten.png'); }); + + it('activates the download control on the first touch sequence', async () => { + screenMocks.isMobile = true; + downloadMedia.mockClear(); + downloadMedia.mockResolvedValue(new Blob(['image'])); + + renderViewer(); + + const download = screen.getByText('Download'); + fireEvent.pointerDown(download, { pointerId: 1, pointerType: 'touch' }); + fireEvent.pointerUp(download, { pointerId: 1, pointerType: 'touch' }); + fireEvent.click(download); + + await waitFor(() => expect(downloadMedia).toHaveBeenCalledOnce()); + screenMocks.isMobile = false; + }); + + it('shows an error toast when downloading media fails', async () => { + const error = new Error('network unavailable'); + downloadMedia.mockRejectedValue(error); + vi.mocked(showToast).mockClear(); + + renderViewer(); + + fireEvent.click(screen.getByText('Download')); + + await waitFor(() => { + expect(showToast).toHaveBeenCalledWith('Failed to download file: network unavailable'); + }); + }); + + it('shows the Android gallery action for trusted image media', () => { + mockPlatform('android'); + + renderViewer({ info: { mimetype: 'image/png' } }); + + fireEvent.contextMenu(screen.getByAltText('kitten.png')); + + expect(screen.getByText('Save to Gallery')).toBeInTheDocument(); + }); + + it('labels the primary action Save to Photos on iOS without duplicating it in the overflow menu', () => { + mockPlatform('ios'); + + renderViewer({ info: { mimetype: 'image/png' } }); + + fireEvent.contextMenu(screen.getByAltText('kitten.png')); + + expect(screen.getAllByText('Save to Photos')).toHaveLength(1); + }); + + it('routes the primary iOS action for trusted images straight to Photos', async () => { + mockPlatform('ios'); + saveMediaToGallery.mockClear(); + downloadMedia.mockClear(); + vi.mocked(FileSaver.saveAs).mockClear(); + + renderViewer({ info: { mimetype: 'image/png' } }); + + fireEvent.click(screen.getByText('Save to Photos')); + + await waitFor(() => + expect(saveMediaToGallery).toHaveBeenCalledWith( + 'https://example.org/kitten.png', + 'kitten.png', + 'image/png' + ) + ); + expect(downloadMedia).not.toHaveBeenCalled(); + expect(FileSaver.saveAs).not.toHaveBeenCalled(); + }); + + it('keeps the iOS primary action on the Files export for videos', async () => { + mockPlatform('ios'); + saveMediaToGallery.mockClear(); + downloadMedia.mockClear(); + downloadMedia.mockResolvedValue(new Blob(['video'])); + + renderViewer({ + alt: 'clip.mp4', + src: 'https://example.org/clip.mp4', + info: { mimetype: 'video/mp4' }, + }); + + expect(screen.getByText('Download')).toBeInTheDocument(); + expect(screen.queryByText('Save to Photos')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Download')); + + await waitFor(() => expect(downloadMedia).toHaveBeenCalledWith('https://example.org/clip.mp4')); + expect(saveMediaToGallery).not.toHaveBeenCalled(); + }); }); vi.mock('$components/media', async () => { @@ -86,25 +209,44 @@ vi.mock('$components/media', async () => { describe('ImageViewer', () => { it('renders the fullscreen image without crashing', () => { - render( {}} />); + renderViewer({ alt: 'demo', src: 'https://example.com/demo.png' }); expect(screen.getByAltText('demo')).toBeInTheDocument(); + expect(screen.getByText('Download').closest('[data-gestures="ignore"]')).not.toBeNull(); }); it('starts viewer gestures from the rendered lottie canvas', () => { gestureMocks.onPointerDown.mockClear(); - render( - {}} - /> - ); + renderViewer({ + alt: 'animated sticker', + src: 'https://example.com/sticker', + info: { mimetype: 'application/x-tgsticker' }, + }); const canvas = screen.getByLabelText('animated sticker'); expect(canvas.tagName).toBe('CANVAS'); fireEvent.pointerDown(canvas); expect(gestureMocks.onPointerDown).toHaveBeenCalled(); }); + + it('contains viewer touches from an enclosing message long-press handler', () => { + const messageLongPress = vi.fn<() => void>(); + render( +
{ + const target = evt.target as Element; + if (target.closest('[data-gestures="ignore"]')) return; + messageLongPress(); + }} + > + {}} /> +
+ ); + + fireEvent.touchStart(screen.getByText('Download'), { + touches: [{ identifier: 1, clientX: 10, clientY: 10 }], + }); + + expect(messageLongPress).not.toHaveBeenCalled(); + }); }); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index ac284b542..1b47901c1 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -19,13 +19,17 @@ import { useMenuAnchor } from '$hooks/useMenuAnchor'; import { useDismissOnBack } from '$utils/androidBack'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; +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 { copyImageToClipboard } from '$utils/dom'; -import { getDownloadFilename, saveFileToDevice } from '$utils/download'; +import { getDownloadFilename, saveFileToDevice, saveMediaToGallery } from '$utils/download'; import { ResponsiveMenu } from '$components/ResponsiveMenu'; +import { isAndroidTauri, iosApp } from '$utils/platform'; +import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; type ImageViewerProps = { alt: string; @@ -39,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, filename, src, requestClose, info, ...props }, ref) => { const zoomInputRef = useRef(null); const [pixelatedImageRendering] = useSetting(settingsAtom, 'pixelatedImageRendering'); + const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; // Android back closes the viewer instead of navigating away. useDismissOnBack(requestClose); @@ -90,12 +95,61 @@ export const ImageViewer = as<'div', ImageViewerProps>( } }, [isEditingZoom]); + const galleryMimeType = info?.mimetype?.toLowerCase(); + // 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'); + const handleDownload = async () => { - const fileContent = await downloadMedia(src); - await saveFileToDevice(fileContent, getDownloadFilename(filename, alt, 'image')); + if (iosSaveToPhotos) { + await saveMediaToGallery(src, downloadFilename, galleryMimeType!); + return; + } + let fileContent: Blob; + try { + fileContent = await downloadMedia(src); + } catch (error) { + const message = error instanceof Error ? error.message : 'unknown error'; + showToast(`Failed to download file: ${message}`); + return; + } + await saveFileToDevice(fileContent, downloadFilename); }; const menu = useMenuAnchor(); + const canSaveToGallery = isAndroidTauri() && (galleryMimeType?.startsWith('image/') ?? false); + + const closeActivation = useMobileTapActivation(isMobile, requestClose); + const pixelatedActivation = useMobileTapActivation(isMobile, () => + setIsPixelated(!isPixelated) + ); + const originalSizeActivation = useMobileTapActivation(isMobile, () => setZoom(1)); + const resetZoomActivation = useMobileTapActivation(isMobile, () => { + resetTransforms(); + enableResizeWithWindow(); + setZoom(fitRatio); + }); + const zoomOutActivation = useMobileTapActivation(isMobile, zoomOut); + const zoomInputActivation = useMobileTapActivation(isMobile, () => { + setZoomInput(Math.round(transforms.zoom * 100).toString()); + setIsEditingZoom(true); + }); + const zoomInActivation = useMobileTapActivation(isMobile, zoomIn); + const downloadActivation = useMobileTapActivation(isMobile, () => { + void handleDownload(); + }); + const copyImageActivation = useMobileTapActivation(isMobile, () => { + menu.close(); + void downloadMedia(src).then(copyImageToClipboard); + }); + const saveImageActivation = useMobileTapActivation(isMobile, () => { + menu.close(); + void handleDownload(); + }); + const galleryActivation = useMobileTapActivation(isMobile, () => { + menu.close(); + void saveMediaToGallery(src, downloadFilename, galleryMimeType!); + }); const handleContextMenu: MouseEventHandler = (evt) => { if (evt.altKey || !window.getSelection()?.isCollapsed) return; @@ -119,11 +173,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="300" size="300" after={menuIcon(CopyIcon)} - onClick={async () => { - menu.close(); - const fileContent = await downloadMedia(src); - await copyImageToClipboard(fileContent); - }} + {...copyImageActivation} > Copy image @@ -134,15 +184,25 @@ export const ImageViewer = as<'div', ImageViewerProps>( radii="300" size="300" after={menuIcon(DownloadIcon)} - onClick={() => { - menu.close(); - handleDownload(); - }} + {...saveImageActivation} > Save image + {canSaveToGallery && ( + + + Save to Gallery + + + )} } @@ -150,12 +210,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
- + {sizedIcon(ArrowLeft, '200')} @@ -167,7 +228,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( variant="Surface" size="300" radii="Pill" - onClick={() => setIsPixelated(!isPixelated)} + {...pixelatedActivation} aria-label="Toggle Pixelation" title={`Turn ${isPixelated ? 'Anti-aliasing' : 'Pixelation'} on`} > @@ -186,9 +247,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( }} size="300" radii="Pill" - onClick={() => { - setZoom(1); - }} + {...originalSizeActivation} aria-label="View Original Size" title="View Original Size" > @@ -205,11 +264,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( }} size="300" radii="Pill" - onClick={() => { - resetTransforms(); - enableResizeWithWindow(); - setZoom(fitRatio); - }} + {...resetZoomActivation} aria-label="Reset Zoom" title="Zoom to Fill Container" > @@ -220,7 +275,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( outlined={transforms.zoom < 1} size="300" radii="Pill" - onClick={zoomOut} + {...zoomOutActivation} aria-label="Zoom Out" title="Zoom Out" > @@ -235,10 +290,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( // 4em should be generous enough to fit without manually determining the width of the text. minWidth: '4em', }} - onClick={() => { - setZoomInput(Math.round(transforms.zoom * 100).toString()); - setIsEditingZoom(true); - }} + {...zoomInputActivation} title="Update Zoom" > ( outlined={transforms.zoom > 1} size="300" radii="Pill" - onClick={zoomIn} + {...zoomInActivation} aria-label="Zoom In" title="Zoom In" > @@ -296,12 +348,12 @@ export const ImageViewer = as<'div', ImageViewerProps>( - Download + {iosSaveToPhotos ? 'Save to Photos' : 'Download'}
diff --git a/src/app/components/message/content/ImageContent.test.tsx b/src/app/components/message/content/ImageContent.test.tsx new file mode 100644 index 000000000..cbd5e1da3 --- /dev/null +++ b/src/app/components/message/content/ImageContent.test.tsx @@ -0,0 +1,126 @@ +import type { ReactNode } from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ImageContent } from './ImageContent'; + +const screenMocks = vi.hoisted(() => ({ isMobile: true })); +vi.mock('$hooks/useScreenSize', () => ({ + ScreenSize: { Desktop: 'Desktop', Tablet: 'Tablet', Mobile: 'Mobile' }, + useScreenSizeOptionally: () => (screenMocks.isMobile ? 'Mobile' : 'Desktop'), +})); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => ({}), +})); +vi.mock('$hooks/useMediaAuthentication', () => ({ + useMediaAuthentication: () => false, +})); +vi.mock('$hooks/useFavoriteGifs', () => ({ + useFavoriteGifs: () => ({ gifs: [] }), +})); +vi.mock('$hooks/useRenderableMediaUrl', () => ({ + useRenderableMediaUrl: (url: string | undefined) => url, +})); +vi.mock('$hooks/useObjectURL', () => ({ + useCreateObjectURL: () => (value: string) => value, +})); + +const imageContent = ( + preview} + renderViewer={() =>
viewer
} + /> +); + +const touchTap = (target: Element) => { + fireEvent.pointerDown(target, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerUp(target, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: 10, + clientY: 10, + }); + fireEvent.click(target); +}; + +// Mirrors Message.tsx: an enclosing long-press timer that media gestures must +// not trigger (media containers are marked `data-gestures="ignore"`). +const renderWithLongPress = (children: ReactNode, onLongPress: () => void) => + render( +
{ + const target = evt.target as Element; + if (target.closest('[data-gestures="ignore"]')) return; + setTimeout(onLongPress, 500); + }} + > + {children} +
+ ); + +describe('ImageContent', () => { + it('opens the viewer after one tap on idle media', async () => { + render(imageContent); + + touchTap(screen.getByRole('button', { name: 'View' })); + + await waitFor(() => expect(screen.getByText('viewer')).toBeInTheDocument()); + expect(screen.getByAltText('preview').closest('[data-gestures="ignore"]')).not.toBeNull(); + }); + + it('does not mount hover controls for touch pointer entry', () => { + render(imageContent); + + const media = screen.getByRole('button', { name: 'View' }).closest('[data-gestures="ignore"]'); + expect(media).not.toBeNull(); + fireEvent.pointerEnter(media!, { pointerType: 'touch' }); + expect(screen.queryByTitle('Hide Image')).not.toBeInTheDocument(); + + fireEvent.pointerEnter(media!, { pointerType: 'mouse' }); + expect(screen.getByTitle('Hide Image')).toBeInTheDocument(); + }); + + it('keeps media touches out of an enclosing message long-press timer', () => { + vi.useFakeTimers(); + const messageLongPress = vi.fn<() => void>(); + try { + renderWithLongPress(imageContent, messageLongPress); + + const view = screen.getByRole('button', { name: 'View' }); + fireEvent.touchStart(view, { + touches: [{ identifier: 1, clientX: 10, clientY: 10 }], + }); + touchTap(view); + vi.advanceTimersByTime(600); + + expect(messageLongPress).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('still allows ordinary message touches to start long press', () => { + vi.useFakeTimers(); + const messageLongPress = vi.fn<() => void>(); + try { + renderWithLongPress(ordinary message, messageLongPress); + + fireEvent.touchStart(screen.getByText('ordinary message'), { + touches: [{ identifier: 1, clientX: 10, clientY: 10 }], + }); + vi.advanceTimersByTime(600); + + expect(messageLongPress).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 87c5b2dd9..d0133b701 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -53,6 +53,8 @@ import { import { useFavoriteGifs } from '$hooks/useFavoriteGifs'; import { useRenderableMediaUrl } from '$hooks/useRenderableMediaUrl'; import { useCreateObjectURL } from '$hooks/useObjectURL'; +import { ScreenSize, useScreenSizeOptionally } from '$hooks/useScreenSize'; +import { useMobileTapActivation } from '$hooks/useMobileTapActivation'; import { ModalOverlay } from '$components/modal-overlay/ModalOverlay'; export function checkIfGif(url: string, mimetype?: string, body?: string) { @@ -136,6 +138,7 @@ export const ImageContent = as<'div', ImageContentProps>( ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const isMobile = useScreenSizeOptionally() === ScreenSize.Mobile; const blurHash = validBlurHash(info?.[MATRIX_UNSTABLE_BLUR_HASH_PROPERTY_NAME]); const [load, setLoad] = useState(false); @@ -216,6 +219,19 @@ export const ImageContent = as<'div', ImageContentProps>( loadSrc().catch(() => undefined); }; + const handleView = async () => { + if (srcState.status !== AsyncStatus.Idle) return; + try { + const src = await loadSrc(); + if (src !== undefined) setViewer(true); + } catch { + // The existing error state is handled by the async callback. + } + }; + const viewActivation = useMobileTapActivation(isMobile, () => { + void handleView(); + }); + useEffect(() => { if (autoPlay) loadSrc().catch(() => undefined); }, [autoPlay, loadSrc]); @@ -250,6 +266,7 @@ export const ImageContent = as<'div', ImageContentProps>( return ( ( }} {...props} ref={ref} - onPointerEnter={() => setIsHovered(true)} - onPointerLeave={() => setIsHovered(false)} + onPointerEnter={(evt) => { + if (evt.pointerType === 'mouse' || evt.pointerType === 'pen') setIsHovered(true); + }} + onPointerLeave={(evt) => { + if (evt.pointerType === 'mouse' || evt.pointerType === 'pen') setIsHovered(false); + }} > {srcState.status === AsyncStatus.Success && ( setViewer(false)}> @@ -291,14 +312,13 @@ export const ImageContent = as<'div', ImageContentProps>( className={css.AbsoluteContainer} alignItems="Center" justifyContent="Center" - onClick={loadSrc} + {...viewActivation} >