diff --git a/src/app/components/image-viewer/ImageViewer.css.ts b/src/app/components/image-viewer/ImageViewer.css.ts index fd2114f65..5606dc45e 100644 --- a/src/app/components/image-viewer/ImageViewer.css.ts +++ b/src/app/components/image-viewer/ImageViewer.css.ts @@ -109,6 +109,7 @@ export const ImageViewerContent = style([ export const ImageViewerContentMobile = style({ backgroundColor: '#000', color: '#fff', + paddingBottom: safeAreaBottom, }); export const ImageViewerInput = style([ @@ -135,13 +136,11 @@ export const ImageViewerImg = style([ maxHeight: 'none', backgroundColor: color.Surface.Container, transition: 'transform 100ms linear', - willChange: 'transform', }, ]); export const ImageViewerImgPixelated = style({ imageRendering: 'pixelated', - willChange: 'auto', }); const mobileGalleryControl = { diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index 3e78517a3..96074363f 100644 --- a/src/app/components/image-viewer/ImageViewer.test.tsx +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -95,6 +95,19 @@ describe('ImageViewer', () => { expect(FileSaver.saveAs).toHaveBeenCalledWith(expect.any(Blob), 'kitten.png'); }); + it("downloads the Matrix source behind Android's sable-media URL", async () => { + const source = 'https://matrix.example.org/_matrix/client/v1/media/download/example.org/kitten'; + const src = `https://sable-media.localhost/${encodeURIComponent(source)}?__sable_media_cache=3`; + downloadMedia.mockResolvedValue(new Blob(['image'])); + + renderViewer({ src }); + fireEvent.click(screen.getByText('Download')); + + await waitFor(() => { + expect(downloadMedia).toHaveBeenCalledWith(source); + }); + }); + it('activates the download control on the first touch sequence', async () => { screenMocks.isMobile = true; downloadMedia.mockClear(); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index d61b15478..700b1e868 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -43,6 +43,7 @@ import type { IImageInfo } from '$types/matrix/common'; import { CheckerboardIcon, CopyIcon, ImagesIcon } from '@phosphor-icons/react'; import { copyImageToClipboard } from '$utils/dom'; import { getDownloadFilename, saveFileToDevice, saveMediaToGallery } from '$utils/download'; +import { getTauriMediaSourceUrl } from '$utils/mediaUrl'; import { ResponsiveMenu } from '$components/ResponsiveMenu'; import { isAndroidTauri, iosApp } from '$utils/platform'; import { setImmersiveMode } from '$generated/tauri/commands'; @@ -160,7 +161,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( } let fileContent: Blob; try { - fileContent = await downloadMedia(src); + fileContent = await downloadMedia(getTauriMediaSourceUrl(src) ?? src); } catch (error) { const message = error instanceof Error ? error.message : 'unknown error'; showToast(`Failed to download file: ${message}`); diff --git a/src/app/hooks/useImageGestures.ts b/src/app/hooks/useImageGestures.ts index ccfdd2923..f0dabe715 100644 --- a/src/app/hooks/useImageGestures.ts +++ b/src/app/hooks/useImageGestures.ts @@ -374,7 +374,7 @@ export const useImageGestures = ( const imageWidth = img instanceof HTMLCanvasElement ? img.width : img.naturalWidth; const heightRatio = height / imageHeight; const widthRatio = width / imageWidth; - const fitZoom = Math.min(heightRatio, widthRatio); + const fitZoom = Math.min(heightRatio, widthRatio, 1); img.style.transition = 'none'; setFitRatio(fitZoom); diff --git a/src/app/utils/mediaUrl.test.ts b/src/app/utils/mediaUrl.test.ts index 4ed9b9c1a..bdcf37519 100644 --- a/src/app/utils/mediaUrl.test.ts +++ b/src/app/utils/mediaUrl.test.ts @@ -20,6 +20,7 @@ vi.mock('./mediaTransport', () => ({ import { addTauriMediaRetryRevision, + getTauriMediaSourceUrl, getTauriMediaRetryTarget, rewriteAuthenticatedMediaUrl, } from './mediaUrl'; @@ -219,3 +220,17 @@ describe('getTauriMediaRetryTarget', () => { } ); }); + +describe('getTauriMediaSourceUrl', () => { + it('unwraps the Android protocol URL to its Matrix media source', () => { + const source = 'https://matrix.example.com/_matrix/client/v1/media/download/example.com/abc123'; + const url = `https://sable-media.localhost/${encodeURIComponent(source)}?__sable_media_cache=3`; + + expect(getTauriMediaSourceUrl(url)).toBe(source); + }); + + it('passes ordinary URLs through unchanged', () => { + const url = 'https://example.org/image.png'; + expect(getTauriMediaSourceUrl(url)).toBe(url); + }); +}); diff --git a/src/app/utils/mediaUrl.ts b/src/app/utils/mediaUrl.ts index d1808f76c..f2302f28e 100644 --- a/src/app/utils/mediaUrl.ts +++ b/src/app/utils/mediaUrl.ts @@ -46,7 +46,7 @@ const TAURI_MEDIA_PROTOCOL = 'sable-media://'; const TAURI_MEDIA_LOCALHOST = 'localhost'; const TAURI_MEDIA_LOCALHOST_HOST = 'sable-media.localhost'; -const getTauriMediaInnerTarget = (mediaUrl: string): string | undefined => { +export const getTauriMediaSourceUrl = (mediaUrl: string): string | undefined => { if (mediaUrl.startsWith(TAURI_MEDIA_PROTOCOL)) { const wrappedUrl = mediaUrl.slice(TAURI_MEDIA_PROTOCOL.length); @@ -88,7 +88,7 @@ export const getTauriMediaRetryTarget = ( revision: number ): string | undefined => { if (revision <= 0 || !isTauri()) return undefined; - const innerTarget = getTauriMediaInnerTarget(mediaUrl); + const innerTarget = getTauriMediaSourceUrl(mediaUrl); if (!innerTarget) return undefined; let parsedUrl: URL; try {