Add centralized saveMedia helpers for image, video, and file downloads.
All checks were successful
Trigger cinny-mobile / dispatch (push) Successful in 2s

Platforms can register a custom saver; viewers share one download/save path instead of FileSaver call sites.
This commit is contained in:
2026-08-09 20:49:00 +10:00
parent 898d217451
commit 9887903f49
6 changed files with 81 additions and 33 deletions

View File

@@ -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 { Box, Button, Chip, Header, IconButton, Input, Menu, PopOut, RectCords, Scroll, Spinner, Text, as, config } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver'; import { saveMediaBlob } from '../../utils/saveMedia';
import * as css from './PdfViewer.css'; import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
@@ -61,8 +61,14 @@ export const PdfViewer = as<'div', PdfViewerProps>(
} }
}, [docState, pageNo, zoom]); }, [docState, pageNo, zoom]);
const handleDownload = () => { const handleDownload = async () => {
FileSaver.saveAs(src, name); 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<HTMLFormElement> = (evt) => { const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {

View File

@@ -6,12 +6,11 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { as } from 'folds'; import { as } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { downloadMedia } from '../../utils/matrix'; import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
const ZOOM_MIN = 1; const ZOOM_MIN = 1;
@@ -270,18 +269,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
const handleDownload = async () => { const handleDownload = async () => {
try { try {
const fileContent = await downloadMedia(src, getCurrentAccessToken()); await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) { } catch (error) {
console.warn('[ImageViewer] Failed to download media:', error); console.warn('[ImageViewer] Failed to download media:', error);
try { try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
window.open(src, '_blank'); window.open(src, '_blank');
} catch {
// ignore
} }
} }
}; };

View File

@@ -2,7 +2,6 @@ import { Badge, Box, IconButton, Spinner, Text, as, toRem } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -14,6 +13,7 @@ import {
mxcUrlToHttp, mxcUrlToHttp,
} from '../../utils/matrix'; } from '../../utils/matrix';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
import { saveMediaBlob } from '../../utils/saveMedia';
const badgeStyles = { maxWidth: toRem(100) }; 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 downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken); : await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent); await saveMediaBlob(fileContent, filename);
FileSaver.saveAs(fileURL, filename); return true;
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]) }, [mx, url, useAuthentication, mimeType, encInfo, filename])
); );

View File

@@ -1,8 +1,8 @@
import React, { ReactNode, useCallback, useState } from 'react'; import React, { ReactNode, useCallback, useState } from 'react';
import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds'; import { Box, Button, Modal, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, Tooltip, TooltipProvider, as } from 'folds';
import { Icon, Icons } from '../../icons'; import { Icon, Icons } from '../../icons';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { saveMediaBlob } from '../../../utils/saveMedia';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common'; import { IFileInfo } from '../../../../types/matrix/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; 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 downloadEncryptedMedia(mediaUrl, (encBuf) => decryptFile(encBuf, mimeType, encInfo), accessToken)
: await downloadMedia(mediaUrl, accessToken); : await downloadMedia(mediaUrl, accessToken);
const fileURL = URL.createObjectURL(fileContent); await saveMediaBlob(fileContent, body);
FileSaver.saveAs(fileURL, body); return fileContent;
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]) }, [mx, url, useAuthentication, mimeType, encInfo, body])
); );
@@ -268,7 +267,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400" size="400"
onClick={() => onClick={() =>
downloadState.status === AsyncStatus.Success downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body) ? saveMediaBlob(downloadState.data, body)
: download() : download()
} }
disabled={downloadState.status === AsyncStatus.Loading} disabled={downloadState.status === AsyncStatus.Loading}

View File

@@ -1,10 +1,9 @@
import React from 'react'; import React from 'react';
import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, IconButton, Text, as } from 'folds'; import { Box, Chip, Header, IconButton, Text, as } from 'folds';
import { Icon, Icons } from '../icons'; import { Icon, Icons } from '../icons';
import * as css from './VideoViewer.css'; import * as css from './VideoViewer.css';
import { downloadMedia } from '../../utils/matrix'; import { downloadAndSaveMedia } from '../../utils/saveMedia';
import { getCurrentAccessToken } from '../../utils/auth'; import { getCurrentAccessToken } from '../../utils/auth';
export type VideoViewerProps = { export type VideoViewerProps = {
@@ -14,20 +13,16 @@ export type VideoViewerProps = {
}; };
export const VideoViewer = as<'div', 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 { try {
const fileContent = await downloadMedia(src, getCurrentAccessToken()); await downloadAndSaveMedia(src, alt, getCurrentAccessToken());
FileSaver.saveAs(fileContent, alt);
} catch (error) { } catch (error) {
console.warn('[VideoViewer] Failed to download media:', error); console.warn('[VideoViewer] Failed to download media:', error);
try { try {
const response = await fetch(src);
if (response.ok) {
const blob = await response.blob();
FileSaver.saveAs(blob, alt);
}
} catch {
window.open(src, '_blank'); window.open(src, '_blank');
} catch {
// ignore
} }
} }
}; };

View File

@@ -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<void>;
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<void> => {
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<void> => {
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);
};