From fd9e4a9802ea9d98a262362bf68d92e0185f4bcf Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 3 Jul 2026 22:30:57 -0400 Subject: [PATCH] feat(download): show a toast + button check when a file is saved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' 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 --- .../components/DeviceVerificationSetup.tsx | 5 ++-- src/app/components/Pdf-viewer/PdfViewer.tsx | 5 ++-- .../components/image-viewer/ImageViewer.tsx | 5 ++-- src/app/components/message/FileHeader.tsx | 16 ++++++++----- .../message/content/FileContent.tsx | 9 +++---- .../features/settings/devices/LocalBackup.tsx | 7 +++--- .../features/toast/LotusToastContainer.tsx | 14 ++++++++--- src/app/hooks/useSaveFile.ts | 24 +++++++++++++++++++ src/app/state/toast.test.ts | 14 ++++++++++- src/app/state/toast.ts | 15 ++++++++++++ 10 files changed, 91 insertions(+), 23 deletions(-) create mode 100644 src/app/hooks/useSaveFile.ts diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 5a971223b..0d02cc347 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -13,9 +13,9 @@ import { color, Spinner, } from 'folds'; -import FileSaver from 'file-saver'; import to from 'await-to-js'; import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; +import { useSaveFile } from '../hooks/useSaveFile'; import { useModalStyle } from '../hooks/useModalStyle'; import { PasswordInput } from './password-input'; import { ContainerColor } from '../styles/ContainerColor.css'; @@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = { }; function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { const [show, setShow] = useState(false); + const saveFile = useSaveFile(); const handleCopy = () => { copyToClipboard(recoveryKey); @@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { const blob = new Blob([recoveryKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + saveFile(blob, 'recovery-key.txt'); }; const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index e3714e5fe..75dde273b 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -19,7 +19,7 @@ import { config, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import FileSaver from 'file-saver'; +import { useSaveFile } from '../../hooks/useSaveFile'; import * as css from './PdfViewer.css'; import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; @@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( ({ className, name, src, requestClose, ...props }, ref) => { const containerRef = useRef(null); const scrollRef = useRef(null); + const saveFile = useSaveFile(); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const [pdfJSState, loadPdfJS] = usePdfJSLoader(); @@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( }, [docState, pageNo, zoom]); const handleDownload = () => { - FileSaver.saveAs(src, name); + saveFile(src, name); }; const handleJumpSubmit: FormEventHandler = (evt) => { diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index e4e54ce75..39761e784 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import FileSaver from 'file-saver'; import classNames from 'classnames'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; +import { useSaveFile } from '../../hooks/useSaveFile'; import * as css from './ImageViewer.css'; import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; @@ -17,12 +17,13 @@ export type ImageViewerProps = { export const ImageViewer = as<'div', ImageViewerProps>( ({ className, alt, src, requestClose, ...props }, ref) => { const { t } = useTranslation(); + const saveFile = useSaveFile(); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const handleDownload = async () => { const fileContent = await downloadMedia(src); - FileSaver.saveAs(fileContent, alt); + saveFile(fileContent, alt); }; return ( diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index fa4a681b3..ffbd9b310 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -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 ( {downloading ? ( ) : ( - + )} ); diff --git a/src/app/components/message/content/FileContent.tsx b/src/app/components/message/content/FileContent.tsx index 0ff889229..80f44c7b8 100644 --- a/src/app/components/message/content/FileContent.tsx +++ b/src/app/components/message/content/FileContent.tsx @@ -14,10 +14,10 @@ import { TooltipProvider, as, } from 'folds'; -import FileSaver from 'file-saver'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FocusTrap from 'focus-trap-react'; import { IFileInfo } from '../../../../types/matrix/common'; +import { useSaveFile } from '../../../hooks/useSaveFile'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { bytesToSize } from '../../../utils/common'; @@ -252,6 +252,7 @@ export type DownloadFileProps = { export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); + const saveFile = useSaveFile(); const [downloadState, download] = useAsyncCallback( useCallback(async () => { @@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil : await downloadMedia(mediaUrl); const fileURL = URL.createObjectURL(fileContent); - FileSaver.saveAs(fileURL, body); + saveFile(fileURL, body); return fileURL; - }, [mx, url, useAuthentication, mimeType, encInfo, body]), + }, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]), ); return downloadState.status === AsyncStatus.Error ? ( @@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil size="400" onClick={() => downloadState.status === AsyncStatus.Success - ? FileSaver.saveAs(downloadState.data, body) + ? saveFile(downloadState.data, body) : download() } disabled={downloadState.status === AsyncStatus.Loading} diff --git a/src/app/features/settings/devices/LocalBackup.tsx b/src/app/features/settings/devices/LocalBackup.tsx index 5c39f1e1f..bf26445fd 100644 --- a/src/app/features/settings/devices/LocalBackup.tsx +++ b/src/app/features/settings/devices/LocalBackup.tsx @@ -1,6 +1,6 @@ import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds'; -import FileSaver from 'file-saver'; +import { useSaveFile } from '../../../hooks/useSaveFile'; import { SequenceCard } from '../../../components/sequence-card'; import { SettingTile } from '../../../components/setting-tile'; import { SequenceCardStyle } from '../styles.css'; @@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker'; function ExportKeys() { const mx = useMatrixClient(); const alive = useAlive(); + const saveFile = useSaveFile(); const [exportState, exportKeys] = useAsyncCallback( useCallback( @@ -28,9 +29,9 @@ function ExportKeys() { const blob = new Blob([encKeys], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'lotus-keys.txt'); + saveFile(blob, 'lotus-keys.txt'); }, - [mx], + [mx, saveFile], ), ); diff --git a/src/app/features/toast/LotusToastContainer.tsx b/src/app/features/toast/LotusToastContainer.tsx index 0b035e746..0f46c41f0 100644 --- a/src/app/features/toast/LotusToastContainer.tsx +++ b/src/app/features/toast/LotusToastContainer.tsx @@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) { onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleCardClick(); }} - aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`} + aria-label={ + toast.roomName + ? `Notification from ${toast.displayName} in ${toast.roomName}` + : `${toast.displayName}: ${toast.body}` + } >
- {toast.avatarUrl ? ( + {toast.iconSrc ? ( + + ) : toast.avatarUrl ? ( ) : (
{toast.body}
-
{toast.roomName}
+ {toast.roomName &&
{toast.roomName}
}
); } diff --git a/src/app/hooks/useSaveFile.ts b/src/app/hooks/useSaveFile.ts new file mode 100644 index 000000000..1d6049e36 --- /dev/null +++ b/src/app/hooks/useSaveFile.ts @@ -0,0 +1,24 @@ +import { useCallback } from 'react'; +import { useSetAtom } from 'jotai'; +import { Icons } from 'folds'; +import FileSaver from 'file-saver'; +import { createDownloadToast, toastQueueAtom } from '../state/toast'; + +/** + * Save a blob/URL to disk AND surface a "Downloaded " toast. + * + * The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved + * files silently — users re-clicked because nothing confirmed success. This gives + * uniform, visible feedback across web + desktop for every download call site. + */ +export const useSaveFile = () => { + const setToast = useSetAtom(toastQueueAtom); + + return useCallback( + (data: Blob | string, filename: string) => { + FileSaver.saveAs(data, filename); + setToast(createDownloadToast(filename, Icons.Check)); + }, + [setToast], + ); +}; diff --git a/src/app/state/toast.test.ts b/src/app/state/toast.test.ts index fbceedffe..18e5b8af7 100644 --- a/src/app/state/toast.test.ts +++ b/src/app/state/toast.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { createStore } from 'jotai'; -import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast'; +import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast'; // The queue lives in an unexported baseAtom; we drive the two write-only setters // (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id) @@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => { ['a'], ); }); + +test('createDownloadToast: filename in body, no room navigation, unique ids', () => { + const a = createDownloadToast('photo.jpg'); + assert.equal(a.displayName, 'Downloaded'); + assert.equal(a.body, 'photo.jpg'); + // roomId empty + an onClick present → clicking dismisses without navigating to a room. + assert.equal(a.roomId, ''); + assert.equal(a.roomName, ''); + assert.equal(typeof a.onClick, 'function'); + const b = createDownloadToast('photo.jpg'); + assert.notEqual(a.id, b.id); +}); diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts index c13e7c40b..239d58f97 100644 --- a/src/app/state/toast.ts +++ b/src/app/state/toast.ts @@ -1,8 +1,10 @@ import { atom } from 'jotai'; +import type { IconSrc } from 'folds'; export type ToastNotif = { id: string; avatarUrl?: string; + iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials) displayName: string; body: string; roomName: string; @@ -12,6 +14,19 @@ export type ToastNotif = { sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click }; +// Build a "download complete" system toast. Kept folds-free here (the icon src is +// passed in) so this stays a pure, testable builder. roomId is empty + onClick is +// set so a click only dismisses (never navigates to a room). +export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({ + id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + displayName: 'Downloaded', + body: filename, + roomName: '', + roomId: '', + iconSrc, + onClick: () => undefined, +}); + const baseAtom = atom([]); // Write-only setter used in ClientNonUIFeatures