mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: hide Undo send when the delay is over and say when an undo fails
This commit is contained in:
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user