diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx new file mode 100644 index 000000000..fb2555fce --- /dev/null +++ b/components/mail/__tests__/undo-send-toast.test.tsx @@ -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>>) => { + const Component = lazy(async () => ({ default: await load() })); + return function Dynamic(props: Record) { + return ; + }; +} })); +vi.mock('@/components/email/email-composer', () => ({ + EmailComposer: ({ onSend }: { onSend?: (data: unknown) => Promise }) => ( + + ), +})); +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: () =>
message list
})); + +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(); + 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(); + 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)); + }); +}); diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 87b74ccbb..caf37937e 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -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 diff --git a/components/ui/__tests__/toast-progress.test.tsx b/components/ui/__tests__/toast-progress.test.tsx index 3eb46e0cd..8ca38a411 100644 --- a/components/ui/__tests__/toast-progress.test.tsx +++ b/components/ui/__tests__/toast-progress.test.tsx @@ -43,6 +43,22 @@ describe('progress toasts', () => { expect(onCancel).toHaveBeenCalled(); }); + it('pauses on hover unless told not to', () => { + render(); + 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(); let id = ''; diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 011282cca..6d11aabf5 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -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) { diff --git a/locales/en/common.json b/locales/en/common.json index 9f27f590c..70addaa33 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/stores/toast-store.ts b/stores/toast-store.ts index e73931200..65b18f287 100644 --- a/stores/toast-store.ts +++ b/stores/toast-store.ts @@ -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, }); }