fix: hide Undo send when the delay is over and say when an undo fails

This commit is contained in:
Linus Rath
2026-10-11 19:13:10 +02:00
parent 8ca4cc5151
commit a5c60ef597
6 changed files with 179 additions and 3 deletions
@@ -0,0 +1,133 @@
import React, { Suspense, lazy } from 'react';
import { act, render, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
import { useToastStore } from '@/stores/toast-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// The Undo send toast outlived the send-delay window: hovering paused it, and
// a click after the release failed without a word.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ onSend }: { onSend?: (data: unknown) => Promise<void> }) => (
<button onClick={() => void onSend?.({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Hello' })}>
stub send
</button>
),
}));
vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
const cancelUndoSend = vi.fn();
const refreshScheduledMetadata = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
useToastStore.setState({ toasts: [] });
const client = {
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 30 } as never);
cancelUndoSend.mockReset();
refreshScheduledMetadata.mockReset().mockResolvedValue(undefined);
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
selectedEmailKeys: new Set(),
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata,
cancelUndoSend,
clearPendingUndoSend: () => useEmailStore.setState({ pendingUndoSend: null }),
pendingUndoSend: null,
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
/** A delayed send the server will release `inMs` from now. */
function sendDelayedBy(inMs: number) {
act(() => {
useEmailStore.setState({
pendingUndoSend: {
submissionId: 'sub-1', emailId: 'email-1', identityId: 'id-1',
sendAt: new Date(Date.now() + inMs).toISOString(), isSmime: false,
},
} as never);
});
}
const undoToast = () => useToastStore.getState().toasts.find((t) => t.action?.label === 'email_viewer.undo_send');
describe('undo send toast', () => {
it('ends before the server releases the message and does not pause on hover', async () => {
render(<MailApp />);
sendDelayedBy(10_000);
await waitFor(() => expect(undoToast()).toBeDefined());
const toast = undoToast()!;
expect(toast.title).toBe('email_viewer.sending_in');
// 30 s is the setting, but only 10 s are left of the window.
expect(toast.duration).toBeLessThan(10_000);
expect(toast.pauseOnHover).toBe(false);
});
it('says so when the undo comes too late', async () => {
cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.'));
vi.spyOn(console, 'error').mockImplementation(() => {});
render(<MailApp />);
sendDelayedBy(10_000);
await waitFor(() => expect(undoToast()).toBeDefined());
act(() => { undoToast()!.action!.onClick(); });
await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.type === 'error' && t.title === 'email_viewer.undo_send_failed')).toBe(true));
});
});
+19 -2
View File
@@ -2165,10 +2165,20 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const undoClient = pending.localAccountId
? (useAuthStore.getState().getClientForAccount(pending.localAccountId) ?? client)
: client;
const undoDurationMs = Math.max(sendDelaySeconds, 8) * 1000;
// A send-delay window (seconds away) closes the toast a second before the
// server releases the message, so Undo is never offered once it is too
// late. A message scheduled for later keeps the toast up a short while.
const remainingMs = Date.parse(pending.sendAt) - Date.now();
const isUndoWindow = Number.isFinite(remainingMs) && remainingMs <= 120_000;
const undoDurationMs = isUndoWindow
? Math.max(remainingMs - 1000, 1000)
: Math.max(sendDelaySeconds, 8) * 1000;
toast.success(t('email_viewer.scheduled_send_created'), {
toast.success(isUndoWindow
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
: t('email_viewer.scheduled_send_created'), {
duration: undoDurationMs,
pauseOnHover: false,
secondaryAction: (pending.emailId && pending.identityId)
? {
label: t('email_viewer.send_now'),
@@ -2204,6 +2214,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isScheduledView) await fetchScheduledEmails(client);
} catch (error) {
console.error('Failed to undo scheduled send:', error);
toast.error(t('email_viewer.undo_send_failed'));
}
})();
},
@@ -2216,6 +2227,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
clearPendingUndoSend();
}
}, undoDurationMs);
// Once the server has released the message, the Scheduled count in the
// sidebar no longer includes it. Not cleared with the effect: clearing the
// pending undo above re-runs this effect before the release.
if (isUndoWindow) {
setTimeout(() => { void refreshScheduledMetadata(client); }, remainingMs + 3000);
}
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -43,6 +43,22 @@ describe('progress toasts', () => {
expect(onCancel).toHaveBeenCalled();
});
it('pauses on hover unless told not to', () => {
render(<Toasts />);
act(() => {
toast.info('Readable', { duration: 1000 });
toast.info('Deadline', { duration: 1000, pauseOnHover: false });
});
act(() => {
screen.getByText('Readable').closest('.toast-item')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
screen.getByText('Deadline').closest('.toast-item')!.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
act(() => { vi.advanceTimersByTime(1500); });
expect(screen.getByText('Readable')).toBeInTheDocument();
expect(screen.queryByText('Deadline')).toBeNull();
});
it('counts down normally once turned into a regular toast', () => {
render(<Toasts />);
let id = '';
+7 -1
View File
@@ -33,6 +33,12 @@ export interface Toast {
* and the toast stays until it is updated or removed.
*/
progress?: number | null;
/**
* Hovering pauses the countdown so a toast can be read. Off for a toast
* whose action stops working at a fixed time, like Undo send: there the
* countdown has to match the time that is left.
*/
pauseOnHover?: boolean;
}
interface ToastProps {
@@ -130,7 +136,7 @@ export function ToastItem({ toast, onClose }: ToastProps) {
exiting ? "toast-exit" : "toast-enter",
toast.onClick && !toast.action && "cursor-pointer"
)}
onMouseEnter={() => setPaused(true)}
onMouseEnter={() => { if (toast.pauseOnHover !== false) setPaused(true); }}
onMouseLeave={() => setPaused(false)}
onClick={() => {
if (toast.onClick && !toast.action) {
+2
View File
@@ -575,6 +575,8 @@
"more": "more",
"scheduled_banner": "Scheduled to send at {date}",
"scheduled_send_created": "Email scheduled for sending",
"sending_in": "Sending in {seconds, plural, one {# second} other {# seconds}}",
"undo_send_failed": "Could not undo. The message may already have been sent.",
"cancel_scheduled_send": "Cancel send",
"reschedule_send": "Reschedule",
"cancel_and_edit": "Cancel and edit",
+2
View File
@@ -55,6 +55,7 @@ interface ToastOptions {
secondaryAction?: ToastAction;
tertiaryAction?: ToastAction;
duration?: number;
pauseOnHover?: boolean;
}
function showToast(type: Toast["type"], title: string, options?: string | ToastOptions, defaultDuration?: number): void {
@@ -67,6 +68,7 @@ function showToast(type: Toast["type"], title: string, options?: string | ToastO
secondaryAction: opts?.secondaryAction,
tertiaryAction: opts?.tertiaryAction,
duration: opts?.duration ?? defaultDuration,
pauseOnHover: opts?.pauseOnHover,
});
}