From 9887903f49e5a3b923724648f3895125bc612743 Mon Sep 17 00:00:00 2001 From: litruv Date: Sun, 9 Aug 2026 20:49:00 +1000 Subject: [PATCH] Add centralized saveMedia helpers for image, video, and file downloads. Platforms can register a custom saver; viewers share one download/save path instead of FileSaver call sites. --- src/app/components/Pdf-viewer/PdfViewer.tsx | 12 +++- .../components/image-viewer/ImageViewer.tsx | 14 ++--- src/app/components/message/FileHeader.tsx | 7 +-- .../message/content/FileContent.tsx | 9 ++- .../components/video-viewer/VideoViewer.tsx | 17 ++---- src/app/utils/saveMedia.ts | 55 +++++++++++++++++++ 6 files changed, 81 insertions(+), 33 deletions(-) create mode 100644 src/app/utils/saveMedia.ts diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 81d89d0..709441a 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -5,7 +5,7 @@ import classNames from 'classnames'; import { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds'; import { Icon, Icons } from '../icons'; import FocusTrap from 'focus-trap-react'; -import FileSaver from 'file-saver'; +import { saveMediaBlob } from '../../utils/saveMedia'; import * as css from './PdfViewer.css'; import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; @@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>( } }, [docState, pageNo, zoom]); - const handleDownload = () => { - FileSaver.saveAs(src, name); + const handleDownload = async () => { + try { + const res = await fetch(src); + if (!res.ok) throw new Error('Failed to fetch PDF'); + await saveMediaBlob(await res.blob(), name); + } catch (error) { + console.warn('[PdfViewer] Failed to download:', error); + } }; const handleJumpSubmit: FormEventHandler = (evt) => { diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 6fec0c0..4e9fd98 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -6,12 +6,11 @@ import React, { useRef, useState, } from 'react'; -import FileSaver from 'file-saver'; import classNames from 'classnames'; import { as } from 'folds'; import { Icon, Icons } from '../icons'; import * as css from './ImageViewer.css'; -import { downloadMedia } from '../../utils/matrix'; +import { downloadAndSaveMedia } from '../../utils/saveMedia'; import { getCurrentAccessToken } from '../../utils/auth'; const ZOOM_MIN = 1; @@ -270,18 +269,13 @@ export const ImageViewer = as<'div', ImageViewerProps>( const handleDownload = async () => { try { - const fileContent = await downloadMedia(src, getCurrentAccessToken()); - FileSaver.saveAs(fileContent, alt); + await downloadAndSaveMedia(src, alt, getCurrentAccessToken()); } catch (error) { console.warn('[ImageViewer] Failed to download media:', error); try { - const response = await fetch(src); - if (response.ok) { - const blob = await response.blob(); - FileSaver.saveAs(blob, alt); - } - } catch { window.open(src, '_blank'); + } catch { + // ignore } } }; diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index f41d94e..d86d5ff 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds'; import { Icon, Icons } from '../icons'; import React, { ReactNode, useCallback } from 'react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import FileSaver from 'file-saver'; import { mimeTypeToExt } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -14,6 +13,7 @@ import { mxcUrlToHttp, } from '../../utils/matrix'; import { getCurrentAccessToken } from '../../utils/auth'; +import { saveMediaBlob } from '../../utils/saveMedia'; const badgeStyles = { maxWidth: toRem(100) }; @@ -36,9 +36,8 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); - const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, filename); - return fileURL; + await saveMediaBlob(fileContent, filename); + return true; }, [mx, url, useAuthentication, mimeType, encInfo, filename]) ); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 72d1ca8..82f9706 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -1,8 +1,8 @@ import React, { ReactNode, useCallback, useState } from 'react'; import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Icon, Icons } from '../../icons'; -import FileSaver from 'file-saver'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import { saveMediaBlob } from '../../../utils/saveMedia'; import FocusTrap from 'focus-trap-react'; import { IFileInfo } from '../../../../types/matrix/common'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; @@ -252,9 +252,8 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil ? await downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken) : await downloadMedia(mediaUrl, accessToken); - const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, body); - return fileURL; + await saveMediaBlob(fileContent, body); + return fileContent; }, [mx, url, useAuthentication, mimeType, encInfo, body]) ); @@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil size="400" onClick={() => downloadState.status === AsyncStatus.Success - ? FileSaver.saveAs(downloadState.data, body) + ? saveMediaBlob(downloadState.data, body) : download() } disabled={downloadState.status === AsyncStatus.Loading} diff --git a/src/app/components/video-viewer/VideoViewer.tsx b/src/app/components/video-viewer/VideoViewer.tsx index 5b0e97f..99a3176 100644 --- a/src/app/components/video-viewer/VideoViewer.tsx +++ b/src/app/components/video-viewer/VideoViewer.tsx @@ -1,10 +1,9 @@ import React from 'react'; -import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, IconButton, Text, as } from 'folds'; import { Icon, Icons } from '../icons'; import * as css from './VideoViewer.css'; -import { downloadMedia } from '../../utils/matrix'; +import { downloadAndSaveMedia } from '../../utils/saveMedia'; import { getCurrentAccessToken } from '../../utils/auth'; export type VideoViewerProps = { @@ -14,20 +13,16 @@ export type VideoViewerProps = { }; export const VideoViewer = as<'div', VideoViewerProps>( - ({ className, alt, src, requestClose, ...props }, ref) => { const handleDownload = async () => { + ({ className, alt, src, requestClose, ...props }, ref) => { + const handleDownload = async () => { try { - const fileContent = await downloadMedia(src, getCurrentAccessToken()); - FileSaver.saveAs(fileContent, alt); + await downloadAndSaveMedia(src, alt, getCurrentAccessToken()); } catch (error) { console.warn('[VideoViewer] Failed to download media:', error); try { - const response = await fetch(src); - if (response.ok) { - const blob = await response.blob(); - FileSaver.saveAs(blob, alt); - } - } catch { window.open(src, '_blank'); + } catch { + // ignore } } }; diff --git a/src/app/utils/saveMedia.ts b/src/app/utils/saveMedia.ts new file mode 100644 index 0000000..826c3b6 --- /dev/null +++ b/src/app/utils/saveMedia.ts @@ -0,0 +1,55 @@ +import FileSaver from 'file-saver'; +import { downloadMedia } from './matrix'; +import { getCurrentAccessToken } from './auth'; + +export type MediaSaver = (blob: Blob, filename: string) => void | Promise; + +let customMediaSaver: MediaSaver | null = null; + +const defaultMediaSaver: MediaSaver = (blob, filename) => { + FileSaver.saveAs(blob, filename); +}; + +/** + * Register a platform-specific media saver (Android MediaStore, Electron dialog, etc.). + * Pass null to restore the default FileSaver path. + * + * Platforms should call this once at startup (mobile overlay / desktop shell entry). + */ +export const registerMediaSaver = (saver: MediaSaver | null): void => { + customMediaSaver = saver; +}; + +/** + * Persist a Blob to disk via the registered platform saver, or FileSaver by default. + */ +export const saveMediaBlob = async (blob: Blob, filename: string): Promise => { + const saver = customMediaSaver ?? defaultMediaSaver; + await saver(blob, filename); +}; + +/** + * Fetch media (auth download or blob:/data:/http URL) and save it via [saveMediaBlob]. + */ +export const downloadAndSaveMedia = async ( + src: string, + filename: string, + accessToken?: string | null +): Promise => { + let blob: Blob; + try { + if (src.startsWith('blob:') || src.startsWith('data:')) { + const res = await fetch(src); + if (!res.ok) throw new Error(`Failed to fetch ${src}`); + blob = await res.blob(); + } else { + blob = await downloadMedia(src, accessToken ?? getCurrentAccessToken()); + } + } catch (error) { + console.warn('[saveMedia] downloadMedia failed, trying fetch fallback:', error); + const res = await fetch(src); + if (!res.ok) throw error; + blob = await res.blob(); + } + await saveMediaBlob(blob, filename); +};