From 0cacac405fe666594bf09757d9a2429fe2a34df0 Mon Sep 17 00:00:00 2001 From: Erwan Leboucher Date: Mon, 31 Aug 2026 09:03:34 +0200 Subject: [PATCH] fix(toast): show failure toasts in the error style --- .changeset/fix-error-toast-styling.md | 5 +++ .../image-viewer/ImageViewer.test.tsx | 12 ++++--- .../components/image-viewer/ImageViewer.tsx | 6 ++-- src/app/components/toast/Toast.tsx | 10 +++--- src/app/features/lobby/HierarchyItemMenu.tsx | 4 +-- src/app/features/room/RoomTimeline.test.tsx | 10 +++--- src/app/features/room/RoomTimeline.tsx | 4 +-- .../settings/account/AnimalCosmetics.tsx | 4 +-- .../settings/account/Profile.test.tsx | 7 ++-- src/app/features/settings/account/Profile.tsx | 8 ++--- src/app/hooks/useRoomMenuActions.ts | 4 +-- .../client-non-ui/notifications.test.tsx | 7 ++-- .../client/client-non-ui/notifications.tsx | 12 +++---- src/app/pages/client/inbox/Notifications.tsx | 4 +-- src/app/state/toast.ts | 14 ++++++-- src/app/utils/download.test.ts | 36 +++++++++++-------- src/app/utils/download.ts | 14 ++++---- 17 files changed, 97 insertions(+), 64 deletions(-) create mode 100644 .changeset/fix-error-toast-styling.md diff --git a/.changeset/fix-error-toast-styling.md b/.changeset/fix-error-toast-styling.md new file mode 100644 index 0000000000..73eef347ad --- /dev/null +++ b/.changeset/fix-error-toast-styling.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix failure toasts showing the green success style with a checkmark: errors such as "Unable to mark this room as read" now use the error colour and a warning icon. diff --git a/src/app/components/image-viewer/ImageViewer.test.tsx b/src/app/components/image-viewer/ImageViewer.test.tsx index 4341782709..02361e6f8a 100644 --- a/src/app/components/image-viewer/ImageViewer.test.tsx +++ b/src/app/components/image-viewer/ImageViewer.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import FileSaver from 'file-saver'; import { ImageViewer } from './ImageViewer'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import type { IImageInfo } from '$types/matrix/common'; const downloadMedia = vi.fn<(src: string) => Promise>(); @@ -11,8 +11,12 @@ const saveMediaToGallery = vi.fn<(input: Blob | string, filename: string, mimeType: string) => Promise>(); const toastMocks = vi.hoisted(() => ({ showToast: vi.fn<(text: string, durationMs?: number) => void>(), + showErrorToast: vi.fn<(text: string, durationMs?: number) => void>(), +})); +vi.mock('$state/toast', () => ({ + showToast: toastMocks.showToast, + showErrorToast: toastMocks.showErrorToast, })); -vi.mock('$state/toast', () => ({ showToast: toastMocks.showToast })); const platformMocks = vi.hoisted(() => ({ isAndroidTauri: vi.fn<() => boolean>(() => false), iosApp: vi.fn<() => boolean>(() => false), @@ -229,14 +233,14 @@ describe('ImageViewer', () => { it('shows an error toast when downloading media fails', async () => { const error = new Error('network unavailable'); downloadMedia.mockRejectedValue(error); - vi.mocked(showToast).mockClear(); + vi.mocked(showErrorToast).mockClear(); renderViewer(); fireEvent.click(screen.getByText('Download')); await waitFor(() => { - expect(showToast).toHaveBeenCalledWith('Failed to download file: network unavailable'); + expect(showErrorToast).toHaveBeenCalledWith('Failed to download file: network unavailable'); }); }); diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index b33ac6bed5..889c352a67 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -36,7 +36,7 @@ import { useMenuAnchor } from '$hooks/useMenuAnchor'; import { useDismissOnBack } from '$utils/androidBack'; import { useSetting } from '$state/hooks/settings'; import { isPixelatedRendering, settingsAtom } from '$state/settings'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { downloadMedia } from '$utils/matrix'; import * as css from './ImageViewer.css'; import type { IImageInfo } from '$types/matrix/common'; @@ -168,7 +168,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( } catch (error) { reportDownloadFailure(error, 'fetch', downloadFilename, galleryMimeType); const message = error instanceof Error ? error.message : 'unknown error'; - showToast(`Failed to save to gallery: ${message}`); + showErrorToast(`Failed to save to gallery: ${message}`); } }; @@ -183,7 +183,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( } catch (error) { reportDownloadFailure(error, 'fetch', downloadFilename, galleryMimeType); const message = error instanceof Error ? error.message : 'unknown error'; - showToast(`Failed to download file: ${message}`); + showErrorToast(`Failed to download file: ${message}`); return; } await saveFileToDevice( diff --git a/src/app/components/toast/Toast.tsx b/src/app/components/toast/Toast.tsx index 364899ae01..30f7fbc061 100644 --- a/src/app/components/toast/Toast.tsx +++ b/src/app/components/toast/Toast.tsx @@ -1,7 +1,7 @@ import { createPortal } from 'react-dom'; import { Box, Text, color, config, toRem } from 'folds'; -import { Check, sizedIcon } from '$components/icons/phosphor'; +import { Check, Warning, sizedIcon } from '$components/icons/phosphor'; import { useToastMessage } from '$state/toast'; import { OVERLAY_LAYER_TOP } from '$components/overlay-stack'; @@ -13,6 +13,8 @@ export function Toast({ container }: ToastProps) { const toast = useToastMessage(); if (!toast || !container) return null; + const isError = toast.status === 'error'; + return createPortal(
- {sizedIcon(Check, '200')} + {sizedIcon(isError ? Warning : Check, '200')} {toast.text} diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 5525d4d9c5..a8f83a614f 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -8,7 +8,7 @@ import type { StateEvents } from '$types/matrix-sdk'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { LeaveSpacePrompt } from '$components/leave-space-prompt'; import { confirm } from '$components/confirm/confirm'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { ResponsiveMenu } from '$components/ResponsiveMenu'; import { useMenuAnchor } from '$hooks/useMenuAnchor'; import { useOpenRoomSettings } from '$state/hooks/roomSettings'; @@ -239,7 +239,7 @@ export function HierarchyItemMenu({ await mx.leave(item.roomId); menu.close(); } catch (e) { - showToast(`Failed to leave room: ${e instanceof Error ? e.message : 'unknown error'}`); + showErrorToast(`Failed to leave room: ${e instanceof Error ? e.message : 'unknown error'}`); } } }; diff --git a/src/app/features/room/RoomTimeline.test.tsx b/src/app/features/room/RoomTimeline.test.tsx index e0de641f1b..8690df89a7 100644 --- a/src/app/features/room/RoomTimeline.test.tsx +++ b/src/app/features/room/RoomTimeline.test.tsx @@ -31,7 +31,7 @@ const { vListProps, timelineSyncOptions, timelineActionsOptions, - showToastMock, + showErrorToastMock, markAsReadMock, readMarkerInLiveTimeline, } = vi.hoisted(() => ({ @@ -91,7 +91,7 @@ const { vListProps: { shift: false, shiftValues: [] as boolean[] }, timelineSyncOptions: { current: undefined as Record | undefined }, timelineActionsOptions: { current: undefined as Record | undefined }, - showToastMock: vi.fn<(text: string) => void>(), + showErrorToastMock: vi.fn<(text: string) => void>(), markAsReadMock: vi.fn<() => void>(), readMarkerInLiveTimeline: { current: false }, })); @@ -157,7 +157,7 @@ vi.mock('$hooks/useRoomNavigate', () => ({ useRoomNavigate: () => ({ navigateRoom: navigateRoomMock }), })); -vi.mock('$state/toast', () => ({ showToast: showToastMock })); +vi.mock('$state/toast', () => ({ showErrorToast: showErrorToastMock })); vi.mock('$hooks/useSpace', () => ({ useSpaceOptionally: () => undefined })); @@ -404,7 +404,7 @@ beforeEach(() => { eventTimeline.current = liveTimeline; liveTimeline.getEvents = () => [{ getId: () => '$evt1' }]; navigateRoomMock.mockReset(); - showToastMock.mockReset(); + showErrorToastMock.mockReset(); markAsReadMock.mockReset(); readMarkerInLiveTimeline.current = false; vListProps.shift = false; @@ -1014,7 +1014,7 @@ describe('jump reveal and focus-regain read receipts', () => { const onJumpError = timelineSyncOptions.current?.onJumpError as (() => void) | undefined; act(() => onJumpError?.()); - expect(showToastMock).toHaveBeenCalledWith('Unable to load this message.'); + expect(showErrorToastMock).toHaveBeenCalledWith('Unable to load this message.'); }); it('clears a notification route when an own message returns to the live timeline', async () => { diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index ba40f2833c..b8aa6bf488 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -45,7 +45,7 @@ import { getMemberDisplayName } from '$utils/room/display'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useSlidingSyncRoomLoading } from '$hooks/useSlidingSyncActiveRoom'; import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { useSpaceOptionally } from '$hooks/useSpace'; import { useIgnoredUsers } from '$hooks/useIgnoredUsers'; import { useImagePackRooms } from '$hooks/useImagePackRooms'; @@ -537,7 +537,7 @@ export function RoomTimeline({ const handleJumpError = useCallback(() => { scrollAnchorRef.current = undefined; setAtBottom(true); - showToast('Unable to load this message.'); + showErrorToast('Unable to load this message.'); }, [setAtBottom]); const handleReturnToLive = useCallback(() => { scrollAnchorRef.current = undefined; diff --git a/src/app/features/settings/account/AnimalCosmetics.tsx b/src/app/features/settings/account/AnimalCosmetics.tsx index 4a197cf2ba..77e1f518f5 100644 --- a/src/app/features/settings/account/AnimalCosmetics.tsx +++ b/src/app/features/settings/account/AnimalCosmetics.tsx @@ -5,7 +5,7 @@ import type { UserProfile } from '$hooks/useUserProfile'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { profilesCacheAtom } from '$state/userRoomProfile'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { invalidateUserProfileCache } from '$hooks/useUserProfile'; import { Box, IconButton, Input, Text } from 'folds'; import { useSetAtom } from 'jotai'; @@ -92,7 +92,7 @@ export function AnimalCosmetics({ profile, userId }: Readonly ({ showToast: vi.fn<(text: string) => void>(), + showErrorToast: vi.fn<(text: string) => void>(), })); const timezoneEditorMock = vi.hoisted(() => ({ @@ -150,6 +151,8 @@ describe('Profile', () => { timezoneEditorMock.props?.onSave('Europe/Paris'); }); - expect(showToast).toHaveBeenCalledWith(expect.stringContaining('Failed to save profile field')); + expect(showErrorToast).toHaveBeenCalledWith( + expect.stringContaining('Failed to save profile field') + ); }); }); diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 07b6793541..81f3a0590c 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -38,7 +38,7 @@ import { NameColorEditor } from './NameColorEditor'; import { StatusEditor } from './StatusEditor'; import { AnimalCosmetics } from './AnimalCosmetics'; import * as prefix from '$unstable/prefixes'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { confirm } from '$components/confirm/confirm'; import { AvatarUploadTile } from '$components/avatar-upload-tile/AvatarUploadTile'; import { accessibleColor } from '$plugins/color'; @@ -158,7 +158,7 @@ function ProfileBanner({ profile, userId }: Readonly) { try { await mx.setExtendedProfileProperty?.(key, value); } catch (error) { - showToast( + showErrorToast( `Failed to save profile field: ${error instanceof Error ? error.message : String(error)}` ); return; diff --git a/src/app/hooks/useRoomMenuActions.ts b/src/app/hooks/useRoomMenuActions.ts index 56b2a0db7f..093f76a5ab 100644 --- a/src/app/hooks/useRoomMenuActions.ts +++ b/src/app/hooks/useRoomMenuActions.ts @@ -13,7 +13,7 @@ import { usePowerLevels } from '$hooks/usePowerLevels'; import { markAsRead } from '$utils/notifications'; import { copyToClipboard } from '$utils/dom'; import { confirm } from '$components/confirm/confirm'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { useOpenRoomSettings } from '$state/hooks/roomSettings'; import { getMatrixToRoom } from '$plugins/matrix-to'; import { getViaServers } from '$plugins/via-servers'; @@ -114,7 +114,7 @@ export function useRoomMenuActions(room: Room) { await mx.leave(room.roomId); return true; } catch (e) { - showToast(`Failed to leave room: ${e instanceof Error ? e.message : 'unknown error'}`); + showErrorToast(`Failed to leave room: ${e instanceof Error ? e.message : 'unknown error'}`); return false; } }, [mx, room.roomId]); diff --git a/src/app/pages/client/client-non-ui/notifications.test.tsx b/src/app/pages/client/client-non-ui/notifications.test.tsx index 7f1857f07c..7d0f7dd1e2 100644 --- a/src/app/pages/client/client-non-ui/notifications.test.tsx +++ b/src/app/pages/client/client-non-ui/notifications.test.tsx @@ -38,7 +38,10 @@ const notificationUtils = vi.hoisted(() => ({ markAsRead: vi.fn<() => Promise>().mockResolvedValue(undefined), })); -const toast = vi.hoisted(() => ({ showToast: vi.fn<(message: string) => void>() })); +const toast = vi.hoisted(() => ({ + showToast: vi.fn<(message: string) => void>(), + showErrorToast: vi.fn<(message: string) => void>(), +})); const getTauriNotificationsApi = vi.hoisted(() => vi.fn<() => Promise>().mockResolvedValue(notificationsApi) @@ -183,6 +186,6 @@ describe('NativeNotificationActionRouting', () => { await waitFor(() => expect(matrixClient.sendMessage).toHaveBeenCalledOnce()); await waitFor(() => expect(store.get(nativeNotificationRepliesAtom)).toEqual([])); expect(notificationUtils.markAsRead).toHaveBeenCalledOnce(); - expect(toast.showToast).not.toHaveBeenCalled(); + expect(toast.showErrorToast).not.toHaveBeenCalled(); }); }); diff --git a/src/app/pages/client/client-non-ui/notifications.tsx b/src/app/pages/client/client-non-ui/notifications.tsx index 9f89aacdd9..46c1594b9f 100644 --- a/src/app/pages/client/client-non-ui/notifications.tsx +++ b/src/app/pages/client/client-non-ui/notifications.tsx @@ -58,7 +58,7 @@ import { } from '$utils/notificationStyle'; import { isMobileOrTablet } from '$utils/platform'; import { createDebugLogger } from '$utils/debugLogger'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { nativeNotificationRepliesAtom, nativeNotificationReplyInFlightAtom, @@ -761,7 +761,7 @@ export function NativeNotificationActionRouting() { !sessionsRef.current.some((session) => session.userId === reply.userId) || !enqueue(reply) ) { - showToast('Reply was not sent. Open the room to retry.'); + showErrorToast('Reply was not sent. Open the room to retry.'); } }; @@ -782,12 +782,12 @@ export function NativeNotificationActionRouting() { const expiresIn = NATIVE_REPLY_EXPIRY_MS - (Date.now() - item.createdAt); if (expiresIn <= 0) { remove(item.key); - showToast('Reply was not sent. Open the room to retry.'); + showErrorToast('Reply was not sent. Open the room to retry.'); return undefined; } const expiryTimer = setTimeout(() => { remove(item.key); - showToast('Reply was not sent. Open the room to retry.'); + showErrorToast('Reply was not sent. Open the room to retry.'); }, expiresIn); const clearExpiryTimer = () => clearTimeout(expiryTimer); if (mx.getUserId() !== item.userId) { @@ -807,7 +807,7 @@ export function NativeNotificationActionRouting() { } if (room.getMyMembership() !== 'join') { remove(item.key); - showToast('Reply was not sent. Open the room to retry.'); + showErrorToast('Reply was not sent. Open the room to retry.'); return clearExpiryTimer; } if (inFlight.has(item.key)) return clearExpiryTimer; @@ -821,7 +821,7 @@ export function NativeNotificationActionRouting() { // notification lingers while later pushes stack onto it. .then(() => markAsRead(mx, item.roomId, hideReads).catch(() => undefined)) .catch(() => { - showToast('Reply was not sent. Open the room to retry.'); + showErrorToast('Reply was not sent. Open the room to retry.'); }) .finally(() => { setInFlight((previous: Set) => { diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 4b6ff1f393..cc40f0f44c 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -24,7 +24,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { showToast } from '$state/toast'; +import { showErrorToast } from '$state/toast'; import { markAsRead } from '$utils/notifications'; import { fetchNotificationEvent } from '$utils/notificationEvent'; import { getRoomAvatarUrl } from '$utils/room/display'; @@ -186,7 +186,7 @@ function NotificationRowItem({ onClick={() => { void markAsRead(mx, room.roomId, hideReads, true) .then(onMarkRead) - .catch(() => showToast('Unable to mark this room as read.')); + .catch(() => showErrorToast('Unable to mark this room as read.')); }} before={sizedIcon(Checks, '100')} > diff --git a/src/app/state/toast.ts b/src/app/state/toast.ts index 612316c74a..0aed6dd568 100644 --- a/src/app/state/toast.ts +++ b/src/app/state/toast.ts @@ -1,6 +1,8 @@ import { useSyncExternalStore } from 'react'; -export type ToastMessage = { id: number; text: string }; +export type ToastStatus = 'success' | 'error'; + +export type ToastMessage = { id: number; text: string; status: ToastStatus }; let current: ToastMessage | null = null; let counter = 0; @@ -11,9 +13,9 @@ const notify = (): void => { listeners.forEach((listener) => listener()); }; -export const showToast = (text: string, durationMs = 3000): void => { +const show = (text: string, status: ToastStatus, durationMs: number): void => { counter += 1; - current = { id: counter, text }; + current = { id: counter, text, status }; notify(); if (timer) clearTimeout(timer); @@ -24,6 +26,12 @@ export const showToast = (text: string, durationMs = 3000): void => { }, durationMs); }; +export const showToast = (text: string, durationMs = 3000): void => + show(text, 'success', durationMs); + +export const showErrorToast = (text: string, durationMs = 3000): void => + show(text, 'error', durationMs); + const subscribe = (listener: () => void): (() => void) => { listeners.add(listener); return () => { diff --git a/src/app/utils/download.test.ts b/src/app/utils/download.test.ts index d7717848a2..55f973abcd 100644 --- a/src/app/utils/download.test.ts +++ b/src/app/utils/download.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import FileSaver from 'file-saver'; import { invoke, isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; -import { showToast } from '$state/toast'; +import { showErrorToast, showToast } from '$state/toast'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; import { downloadJsonFile, @@ -29,6 +29,7 @@ const mocks = vi.hoisted(() => ({ isTauri: vi.fn<() => boolean>(), osType: vi.fn<() => string>(), showToast: vi.fn<(text: string, durationMs?: number) => void>(), + showErrorToast: vi.fn<(text: string, durationMs?: number) => void>(), fetchMediaBlob: vi.fn<(input: string) => Promise>(), captureException: vi.fn<(error: unknown, context?: unknown) => void>(), setMediaEncryption: vi.fn<() => Promise>(), @@ -42,7 +43,10 @@ vi.mock('@tauri-apps/api/core', () => ({ })); vi.mock('@tauri-apps/plugin-os', () => ({ type: mocks.osType })); vi.mock('@sentry/react', () => ({ captureException: mocks.captureException })); -vi.mock('$state/toast', () => ({ showToast: mocks.showToast })); +vi.mock('$state/toast', () => ({ + showToast: mocks.showToast, + showErrorToast: mocks.showErrorToast, +})); vi.mock('$utils/mediaTransport', () => ({ fetchMediaBlob: mocks.fetchMediaBlob })); vi.mock('$utils/tauriMediaEncryption', () => ({ setMediaEncryption: mocks.setMediaEncryption })); vi.mock('tauri-plugin-android-fs-api', () => ({ @@ -147,7 +151,7 @@ describe('saveFileToDevice', () => { expect(result).toBe('failed'); expect(androidFs.removeFile).toHaveBeenCalledWith('content://download/file'); - expect(showToast).toHaveBeenCalledWith('Failed to save file: write failed'); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save file: write failed'); }); it('does not write or toast when the iOS save picker is cancelled', async () => { @@ -159,6 +163,7 @@ describe('saveFileToDevice', () => { expect(save).toHaveBeenCalledWith({ defaultPath: 'file.txt' }); expect(writeFile).not.toHaveBeenCalled(); expect(showToast).not.toHaveBeenCalled(); + expect(showErrorToast).not.toHaveBeenCalled(); }); it('writes the selected iOS path and shows the success toast', async () => { @@ -305,7 +310,7 @@ describe('saveMediaToGallery', () => { expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); expect(androidFs.writeFile).not.toHaveBeenCalled(); - expect(showToast).toHaveBeenCalledWith( + expect(showErrorToast).toHaveBeenCalledWith( 'Failed to save to gallery: Storage permission was denied' ); }); @@ -317,7 +322,7 @@ describe('saveMediaToGallery', () => { expect(androidFs.writeFile).not.toHaveBeenCalled(); expect(androidFs.removeFile).not.toHaveBeenCalled(); - expect(showToast).toHaveBeenCalledWith('Failed to save to gallery: create failed'); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save to gallery: create failed'); }); it('rejects video media explicitly without touching any backend or falling back', async () => { @@ -328,6 +333,7 @@ describe('saveMediaToGallery', () => { expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled(); expect(showToast).not.toHaveBeenCalled(); + expect(showErrorToast).not.toHaveBeenCalled(); expect(FileSaver.saveAs).not.toHaveBeenCalled(); }); @@ -337,7 +343,7 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery(new Blob(['data']), 'photo.png', 'image/png'); expect(androidFs.removeFile).toHaveBeenCalledWith('content://media/image'); - expect(showToast).toHaveBeenCalledWith('Failed to save to gallery: write failed'); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save to gallery: write failed'); expect(invoke).not.toHaveBeenCalled(); expect(FileSaver.saveAs).not.toHaveBeenCalled(); }); @@ -351,7 +357,7 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery(new Blob(['data']), 'photo.png', 'image/png'); expect(androidFs.removeFile).toHaveBeenCalledWith('content://media/image'); - expect(showToast).toHaveBeenCalledWith(`Failed to save to gallery: ${error.message}`); + expect(showErrorToast).toHaveBeenCalledWith(`Failed to save to gallery: ${error.message}`); }); it('sends media bytes to the native Photos command on iOS', async () => { @@ -394,7 +400,7 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery(new Blob(['data']), 'photo.png', 'image/png'); - expect(showToast).toHaveBeenCalledWith('Failed to save to photos: photos unavailable'); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save to photos: photos unavailable'); }); it('rejects non-media types without touching any backend', async () => { @@ -405,6 +411,7 @@ describe('saveMediaToGallery', () => { expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled(); expect(showToast).not.toHaveBeenCalled(); + expect(showErrorToast).not.toHaveBeenCalled(); }); it('rejects on desktop platforms and in the browser instead of falling back', async () => { @@ -427,8 +434,8 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery('mxc://example/photo.png', 'photo.png', 'image/png'); - expect(showToast).toHaveBeenCalledTimes(1); - expect(showToast).toHaveBeenCalledWith('Failed to save to gallery: network down'); + expect(showErrorToast).toHaveBeenCalledTimes(1); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save to gallery: network down'); expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); expect(androidFs.removeFile).not.toHaveBeenCalled(); expect(invoke).not.toHaveBeenCalled(); @@ -440,7 +447,7 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery('mxc://example/missing.png', 'missing.png', 'image/png'); - expect(showToast).toHaveBeenCalledWith( + expect(showErrorToast).toHaveBeenCalledWith( 'Failed to save to gallery: Failed to fetch media: 404 Not Found' ); expect(androidFs.createNewPublicImageFile).not.toHaveBeenCalled(); @@ -454,8 +461,8 @@ describe('saveMediaToGallery', () => { await saveMediaToGallery('mxc://example/photo.png', 'photo.png', 'image/png'); - expect(showToast).toHaveBeenCalledTimes(1); - expect(showToast).toHaveBeenCalledWith('Failed to save to photos: decode failed'); + expect(showErrorToast).toHaveBeenCalledTimes(1); + expect(showErrorToast).toHaveBeenCalledWith('Failed to save to photos: decode failed'); expect(invoke).not.toHaveBeenCalled(); expect(FileSaver.saveAs).not.toHaveBeenCalled(); }); @@ -531,6 +538,7 @@ describe('saveMediaToDevice', () => { await expect(saveMediaToDevice(options())).resolves.toBe('cancelled'); expect(mocks.showToast).not.toHaveBeenCalled(); + expect(mocks.showErrorToast).not.toHaveBeenCalled(); }); it('keeps Android on the public Downloads directory instead of the desktop command', async () => { @@ -553,7 +561,7 @@ describe('saveMediaToDevice', () => { await expect(saveMediaToDevice(opts)).resolves.toBe('failed'); expect(mocks.captureException).toHaveBeenCalledOnce(); - expect(mocks.showToast).toHaveBeenCalledExactlyOnceWith('Failed to save file: offline'); + expect(mocks.showErrorToast).toHaveBeenCalledExactlyOnceWith('Failed to save file: offline'); expect(FileSaver.saveAs).not.toHaveBeenCalled(); }); }); diff --git a/src/app/utils/download.ts b/src/app/utils/download.ts index c9478311fc..16505ba524 100644 --- a/src/app/utils/download.ts +++ b/src/app/utils/download.ts @@ -3,7 +3,7 @@ import * as Sentry from '@sentry/react'; import { invoke, isTauri } from '@tauri-apps/api/core'; import { type as osType } from '@tauri-apps/plugin-os'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { showToast } from '$state/toast'; +import { showErrorToast, showToast } from '$state/toast'; import { fetchMediaBlob } from '$utils/mediaTransport'; import { setMediaEncryption } from '$utils/tauriMediaEncryption'; @@ -163,7 +163,7 @@ export async function saveMediaToGallery( showToast('Saved to Gallery'); } catch (error) { reportSaveFailure(error, 'gallery', filename, mediaMimeType); - showToast(`Failed to save to gallery: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save to gallery: ${getErrorMessage(error)}`); } return; } @@ -180,7 +180,7 @@ export async function saveMediaToGallery( showToast('Saved to Photos'); } catch (error) { reportSaveFailure(error, 'photos', filename, mediaMimeType); - showToast(`Failed to save to photos: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save to photos: ${getErrorMessage(error)}`); } } @@ -194,7 +194,7 @@ export async function saveFileToDevice( blob = await resolveBlob(input); } catch (error) { reportDownloadFailure(error, 'fetch', filename, mimeType); - showToast(`Failed to save file: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save file: ${getErrorMessage(error)}`); return 'failed'; } @@ -245,7 +245,7 @@ export async function saveFileToDevice( return saved ? 'saved' : 'cancelled'; } catch (error) { reportSaveFailure(error, 'downloads', filename, mimeType || blob.type || undefined); - showToast(`Failed to save file: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save file: ${getErrorMessage(error)}`); return 'failed'; } } @@ -254,7 +254,7 @@ export async function saveFileToDevice( FileSaver.saveAs(blob, filename); } catch (error) { reportDownloadFailure(error, 'save', filename, mimeType || blob.type || undefined); - showToast(`Failed to save file: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save file: ${getErrorMessage(error)}`); return 'failed'; } return 'saved'; @@ -283,7 +283,7 @@ export async function saveMediaToDevice({ blob = await loadBlob(); } catch (error) { reportDownloadFailure(error, 'fetch', filename, mimeType); - showToast(`Failed to save file: ${getErrorMessage(error)}`); + showErrorToast(`Failed to save file: ${getErrorMessage(error)}`); return 'failed'; } return saveFileToDevice(blob, filename, mimeType);