feat(download): show a toast + button check when a file is saved

The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.

Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.

Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:30:57 -04:00
co-authored by Claude Opus 4.8
parent f12175e76f
commit fd9e4a9802
10 changed files with 91 additions and 23 deletions
+10 -6
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback(
useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename);
saveFile(fileURL, filename);
return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]),
}, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
);
const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return (
<IconButton
disabled={downloading}
onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'}
variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300"
radii="300"
aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...'
: hasError
? 'Download failed, click to retry'
: 'Download file'
: succeeded
? 'Downloaded — click to download again'
: 'Download file'
}
>
{downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : (
<Icon size="100" src={Icons.Download} />
<Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)}
</IconButton>
);