mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: polish scheduled send follow-ups
This commit is contained in:
@@ -5,8 +5,15 @@ import { EmailComposer } from '../email-composer';
|
||||
|
||||
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
|
||||
|
||||
const editorKeyDown = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/components/email/rich-text-editor', () => ({
|
||||
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
|
||||
RichTextEditor: () => React.createElement('div', {
|
||||
'data-testid': 'rich-text-editor',
|
||||
onKeyDown: (event: React.KeyboardEvent) => {
|
||||
if (!event.defaultPrevented) editorKeyDown();
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
@@ -192,6 +199,7 @@ const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButton
|
||||
describe('composer empty subject warning', () => {
|
||||
beforeEach(() => {
|
||||
updateSetting.mockClear();
|
||||
editorKeyDown.mockClear();
|
||||
});
|
||||
|
||||
it('keeps Send enabled when the subject is empty', () => {
|
||||
@@ -209,6 +217,17 @@ describe('composer empty subject warning', () => {
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('intercepts Ctrl+Enter before the editor inserts a newline', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('rich-text-editor'), { key: 'Enter', ctrlKey: true });
|
||||
|
||||
expect(await screen.findByText('empty_subject.title')).toBeInTheDocument();
|
||||
expect(editorKeyDown).not.toHaveBeenCalled();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends with an empty subject once confirmed', async () => {
|
||||
const onSend = vi.fn();
|
||||
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
|
||||
|
||||
@@ -565,6 +565,7 @@ export function EmailComposer({
|
||||
const [scheduleError, setScheduleError] = useState('');
|
||||
const [showSendMenu, setShowSendMenu] = useState(false);
|
||||
const sendMenuRef = useRef<HTMLDivElement>(null);
|
||||
const mobileSendMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const saveTemplateModalRef = useFocusTrap({
|
||||
isActive: showSaveAsTemplate,
|
||||
@@ -696,7 +697,7 @@ export function EmailComposer({
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutsideSendMenu = (event: MouseEvent) => {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node)) {
|
||||
if (!sendMenuRef.current?.contains(event.target as Node) && !mobileSendMenuRef.current?.contains(event.target as Node)) {
|
||||
setShowSendMenu(false);
|
||||
}
|
||||
};
|
||||
@@ -2099,30 +2100,6 @@ export function EmailComposer({
|
||||
handleSend({ delayedUntil: new Date(scheduleValue).toISOString() });
|
||||
};
|
||||
|
||||
// Ctrl+Enter (Win/Linux) / Cmd+Enter (macOS) sends the open compose
|
||||
// draft. Scoped to events whose target lives inside this composer's
|
||||
// DOM tree — in Pro mode multiple composer tabs can be mounted at
|
||||
// once (inactive tabs are CSS-hidden, not unmounted), so a window
|
||||
// listener would otherwise fire every mounted composer's handleSend
|
||||
// on a single keystroke. handleSend is rebound every render, so we
|
||||
// route through a ref to keep the listener stable.
|
||||
const composerRootRef = useRef<HTMLDivElement | null>(null);
|
||||
const handleSendRef = useRef<typeof handleSend | undefined>(undefined);
|
||||
handleSendRef.current = handleSend;
|
||||
useEffect(() => {
|
||||
const handleSendShortcut = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Enter') return;
|
||||
if (!(e.ctrlKey || e.metaKey)) return;
|
||||
if (e.altKey || e.shiftKey) return;
|
||||
const root = composerRootRef.current;
|
||||
if (!root || !(e.target instanceof Node) || !root.contains(e.target)) return;
|
||||
e.preventDefault();
|
||||
void handleSendRef.current?.();
|
||||
};
|
||||
window.addEventListener('keydown', handleSendShortcut);
|
||||
return () => window.removeEventListener('keydown', handleSendShortcut);
|
||||
}, []);
|
||||
|
||||
const cleanClose = () => {
|
||||
sendCancelledRef.current = true;
|
||||
explicitCloseRef.current = true;
|
||||
@@ -2237,7 +2214,11 @@ export function EmailComposer({
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={composerRootRef} data-testid="email-composer" className={cn("flex h-full bg-background", className)}>
|
||||
<div
|
||||
data-testid="email-composer"
|
||||
className={cn("flex h-full bg-background", className)}
|
||||
onKeyDownCapture={handleComposerKeyDown}
|
||||
>
|
||||
<PluginSlot
|
||||
name="composer-sidebar"
|
||||
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
|
||||
@@ -2250,7 +2231,6 @@ export function EmailComposer({
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
>
|
||||
{/* Drag overlay */}
|
||||
{isDraggingOver && (
|
||||
@@ -2289,18 +2269,59 @@ export function EmailComposer({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Mobile: send button in header */}
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
|
||||
{composerClient?.hasDelayedSend() ? (
|
||||
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="h-9 rounded-e-none border-e border-primary-foreground/20 px-3"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowSendMenu((open) => !open)}
|
||||
disabled={!canSend || isSending}
|
||||
title={t('schedule_send')}
|
||||
size="sm"
|
||||
className="h-9 rounded-s-none px-2"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSendMenu}
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
{showSendMenu && (
|
||||
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={openScheduleDialog}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{t('schedule_send')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleSend()}
|
||||
disabled={!canSend || isSending}
|
||||
title={getSendTooltip()}
|
||||
size="sm"
|
||||
data-testid="composer-send"
|
||||
className="md:hidden h-9 px-4"
|
||||
>
|
||||
<Send className="w-4 h-4 me-1.5" />
|
||||
{t('send')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
ShieldCheck,
|
||||
EditIcon,
|
||||
CalendarClock,
|
||||
XCircle,
|
||||
Paperclip,
|
||||
Link as LinkIcon,
|
||||
MessagesSquare,
|
||||
@@ -74,7 +73,6 @@ interface EmailContextMenuProps {
|
||||
onMarkAsSpam?: () => void;
|
||||
onUndoSpam?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: () => void;
|
||||
// Batch actions
|
||||
@@ -135,7 +133,6 @@ export function EmailContextMenu({
|
||||
onBatchMarkAsSpam,
|
||||
onBatchUndoSpam,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailContextMenuProps) {
|
||||
@@ -210,12 +207,6 @@ export function EmailContextMenu({
|
||||
onClick={() => handleAction(onRescheduleScheduled!)}
|
||||
disabled={!onRescheduleScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={XCircle}
|
||||
label={t("cancel_scheduled_send")}
|
||||
onClick={() => handleAction(onCancelScheduled!)}
|
||||
disabled={!onCancelScheduled}
|
||||
/>
|
||||
<ContextMenuItem
|
||||
icon={EditIcon}
|
||||
label={email.isSmimeScheduled ? t("cancel_and_compose_again") : t("cancel_and_edit")}
|
||||
|
||||
@@ -47,7 +47,6 @@ interface EmailListProps {
|
||||
onEditDraft?: (email: Email) => void;
|
||||
isScheduledView?: boolean;
|
||||
onLoadMoreScheduled?: () => void;
|
||||
onCancelScheduled?: (email: Email) => void | Promise<void>;
|
||||
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
||||
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
||||
}
|
||||
@@ -78,7 +77,6 @@ export function EmailList({
|
||||
onEditDraft,
|
||||
isScheduledView = false,
|
||||
onLoadMoreScheduled,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
}: EmailListProps) {
|
||||
@@ -608,7 +606,6 @@ export function EmailList({
|
||||
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
||||
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
||||
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
||||
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
||||
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
||||
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
||||
onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)}
|
||||
|
||||
@@ -136,7 +136,6 @@ interface EmailViewerProps {
|
||||
onNavigatePrev?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
onEditDraft?: () => void;
|
||||
onCancelScheduled?: () => void;
|
||||
onCancelScheduledForEdit?: () => void;
|
||||
onRescheduleScheduled?: (delayedUntil: string) => void;
|
||||
onCompose?: () => void;
|
||||
@@ -636,7 +635,6 @@ export function EmailViewer({
|
||||
onNavigatePrev,
|
||||
onShowShortcuts,
|
||||
onEditDraft,
|
||||
onCancelScheduled,
|
||||
onCancelScheduledForEdit,
|
||||
onRescheduleScheduled,
|
||||
onCompose,
|
||||
@@ -2854,16 +2852,12 @@ export function EmailViewer({
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
|
||||
className="sm:flex sm:h-8"
|
||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('send_now')}
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('send_now')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduled} className="sm:flex sm:h-8" title={t('cancel_scheduled_send')}>
|
||||
<X className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('cancel_scheduled_send')}</span>}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -2871,13 +2865,13 @@ export function EmailViewer({
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||
}}
|
||||
className="hidden sm:flex sm:h-8"
|
||||
className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0"
|
||||
title={t('reschedule_send')}
|
||||
>
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{t('reschedule_send')}</span>}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<Button variant="ghost" size="sm" onClick={onCancelScheduledForEdit} className="hidden sm:flex sm:h-8 sm:gap-1.5 sm:py-0" title={email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}>
|
||||
<EditIcon className="w-4 h-4" />
|
||||
{showToolbarLabels && <span className="hidden sm:inline text-sm">{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}</span>}
|
||||
</Button>
|
||||
@@ -4409,18 +4403,25 @@ export function EmailViewer({
|
||||
{/* Scheduled Banner */}
|
||||
{isScheduled && (
|
||||
<div className="border-b border-border bg-primary/10">
|
||||
<div className="max-w-4xl mx-auto px-6 py-2.5 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="px-4 sm:px-6 py-2.5 flex flex-col sm:flex-row sm:flex-wrap sm:items-center sm:justify-between gap-2">
|
||||
<div className="flex items-center gap-2 text-primary">
|
||||
<CalendarClock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">
|
||||
{t('scheduled_banner', { date: email.scheduledSendAt ? formatDateTime(email.scheduledSendAt, timeFormat) : '' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{canCancelScheduled && (
|
||||
<>
|
||||
<Button size="sm" variant="default" onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}>{t('send_now')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduled}>{t('cancel_scheduled_send')}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onRescheduleScheduled?.(new Date(Date.now() + 1000).toISOString())}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{t('send_now')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -4428,10 +4429,13 @@ export function EmailViewer({
|
||||
const delayedUntil = promptForRescheduleDelayedUntil();
|
||||
if (delayedUntil) onRescheduleScheduled?.(delayedUntil);
|
||||
}}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<CalendarClock className="w-3.5 h-3.5" />
|
||||
{t('reschedule_send')}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit}>
|
||||
<Button size="sm" variant="outline" onClick={onCancelScheduledForEdit} className="gap-1.5">
|
||||
<EditIcon className="w-3.5 h-3.5" />
|
||||
{email.isSmimeScheduled ? t('cancel_and_compose_again') : t('cancel_and_edit')}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, Fragment, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -985,6 +985,22 @@ export function Sidebar({
|
||||
})
|
||||
: [];
|
||||
|
||||
const renderScheduledRow = (key: string) => showScheduledMailbox ? (
|
||||
<SidebarRow
|
||||
key={key}
|
||||
icon={<CalendarClock className="w-4 h-4 flex-shrink-0 text-sky-600 dark:text-sky-400" />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
testRole="scheduled"
|
||||
testName="scheduled"
|
||||
testMailboxId="__scheduled__"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const getUnifiedIcon = (role: UnifiedMailboxRole) => {
|
||||
switch (role) {
|
||||
case 'inbox': return Inbox;
|
||||
@@ -1235,32 +1251,24 @@ export function Sidebar({
|
||||
) : (
|
||||
<>
|
||||
{tree.map((node) => (
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
<Fragment key={node.id}>
|
||||
<MailboxTreeItem
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword || !isViewing ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
onMailboxSelect={(mailboxId) =>
|
||||
onAccountMailboxSelect?.(isActive ? null : account.id, mailboxId)
|
||||
}
|
||||
onToggleExpand={handleToggleExpand}
|
||||
isCollapsed={isCollapsed}
|
||||
onUnreadFilterClick={isActive ? onUnreadFilterClick : undefined}
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={isActive ? handleMailboxContextMenu : undefined}
|
||||
/>
|
||||
{isActive && node.role === 'drafts' && renderScheduledRow(`scheduled-${account.id}`)}
|
||||
</Fragment>
|
||||
))}
|
||||
{isActive && showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{isActive && !tree.some((node) => node.role === 'drafts') && renderScheduledRow(`scheduled-${account.id}`)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
@@ -1288,8 +1296,8 @@ export function Sidebar({
|
||||
) : (
|
||||
<>
|
||||
{ownTree.map((node) => (
|
||||
<Fragment key={node.id}>
|
||||
<MailboxTreeItem
|
||||
key={node.id}
|
||||
node={node}
|
||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||
expandedFolders={expandedFolders}
|
||||
@@ -1300,18 +1308,10 @@ export function Sidebar({
|
||||
colorful={colorfulSidebarIcons}
|
||||
onContextMenu={handleMailboxContextMenu}
|
||||
/>
|
||||
{node.role === 'drafts' && renderScheduledRow('scheduled')}
|
||||
</Fragment>
|
||||
))}
|
||||
{showScheduledMailbox && (
|
||||
<SidebarRow
|
||||
icon={<CalendarClock className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__scheduled__' ? "text-foreground" : "text-muted-foreground")} />}
|
||||
label={t('scheduled')}
|
||||
depth={0}
|
||||
isSelected={!selectedKeyword && selectedMailbox === '__scheduled__'}
|
||||
total={scheduledTotal}
|
||||
onClick={() => onMailboxSelect?.('__scheduled__')}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
{!ownTree.some((node) => node.role === 'drafts') && renderScheduledRow('scheduled')}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -337,7 +337,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
setScheduledView,
|
||||
fetchScheduledEmails,
|
||||
loadMoreScheduledEmails,
|
||||
cancelScheduledEmail,
|
||||
cancelScheduledEmailForEdit,
|
||||
rescheduleScheduledEmail,
|
||||
refreshScheduledMetadata,
|
||||
@@ -459,6 +458,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
|
||||
const handleNavRestore = useCallback(async (state: NavSnapshot) => {
|
||||
const ctx = navRestoreStateRef.current;
|
||||
let restoredEmails = ctx.emails;
|
||||
|
||||
// Restore sidebar overlay state.
|
||||
setSidebarOpen(state.sidebarOpen);
|
||||
@@ -489,6 +489,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
if (ctx.client) {
|
||||
try {
|
||||
await fetchScheduledEmails(ctx.client);
|
||||
restoredEmails = useEmailStore.getState().scheduledEmails;
|
||||
} catch (error) {
|
||||
debug.error('Failed to fetch scheduled emails on history restore:', error);
|
||||
}
|
||||
@@ -522,7 +523,7 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
} else {
|
||||
// Try the in-memory list first; the existing useEffect will fetch
|
||||
// body content if it's missing.
|
||||
const found = ctx.emails.find(e => e.id === state.emailId);
|
||||
const found = restoredEmails.find(e => e.id === state.emailId);
|
||||
if (found) {
|
||||
selectEmail(found);
|
||||
} else if (ctx.client) {
|
||||
@@ -3533,9 +3534,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
isLoadingMoreItems={isScheduledView ? isLoadingScheduled && activeEmails.length > 0 : undefined}
|
||||
isScheduledView={isScheduledView}
|
||||
onLoadMoreScheduled={() => client && loadMoreScheduledEmails(client)}
|
||||
onCancelScheduled={async (email) => {
|
||||
if (client && email.emailSubmissionId) await cancelScheduledEmail(client, email.emailSubmissionId, email.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async (email) => {
|
||||
if (!client) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, email);
|
||||
@@ -3847,9 +3845,6 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
onNavigatePrev={handleNavigatePrev}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
onEditDraft={handleEditDraft}
|
||||
onCancelScheduled={async () => {
|
||||
if (client && selectedEmail?.emailSubmissionId) await cancelScheduledEmail(client, selectedEmail.emailSubmissionId, selectedEmail.id);
|
||||
}}
|
||||
onCancelScheduledForEdit={async () => {
|
||||
if (!client || !selectedEmail) return;
|
||||
const restored = await cancelScheduledEmailForEdit(client, selectedEmail);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const apiFetch = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
|
||||
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
|
||||
describe('settings sync debounce', () => {
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
apiFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}));
|
||||
useSettingsStore.getState().disableSync();
|
||||
useSettingsStore.setState({ sendDelaySeconds: 0 });
|
||||
});
|
||||
|
||||
it('flushes a pending snapshot for the account that scheduled it', async () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
settings.enableSync('a@example.com', 'https://mail-a.example.com');
|
||||
settings.updateSetting('sendDelaySeconds', 10);
|
||||
|
||||
await useSettingsStore.getState().flushSync();
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledTimes(1);
|
||||
const request = apiFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(request.body as string)).toMatchObject({
|
||||
username: 'a@example.com',
|
||||
serverUrl: 'https://mail-a.example.com',
|
||||
settings: { sendDelaySeconds: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not rewrite a pending account snapshot after another account becomes active', async () => {
|
||||
const settings = useSettingsStore.getState();
|
||||
settings.enableSync('a@example.com', 'https://mail-a.example.com');
|
||||
settings.updateSetting('sendDelaySeconds', 10);
|
||||
|
||||
const flush = useSettingsStore.getState().flushSync();
|
||||
useSettingsStore.getState().disableSync();
|
||||
useSettingsStore.getState().enableSync('b@example.com', 'https://mail-b.example.com');
|
||||
useSettingsStore.getState().updateSetting('sendDelaySeconds', 30);
|
||||
await flush;
|
||||
|
||||
const request = apiFetch.mock.calls[0][1] as RequestInit;
|
||||
expect(JSON.parse(request.body as string)).toMatchObject({
|
||||
username: 'a@example.com',
|
||||
settings: { sendDelaySeconds: 10 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1236,6 +1236,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
accountStore.removeAccount(accountId);
|
||||
}
|
||||
|
||||
await useSettingsStore.getState().flushSync();
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
await authHooks.onAfterLogout.emit({
|
||||
@@ -1406,6 +1407,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
snapshotAccount(state.activeAccountId);
|
||||
}
|
||||
clearAllStores();
|
||||
await useSettingsStore.getState().flushSync();
|
||||
useSettingsStore.getState().disableSync();
|
||||
|
||||
// Client not connected - try to restore
|
||||
@@ -1546,6 +1548,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
snapshotAccount(state.activeAccountId);
|
||||
}
|
||||
clearAllStores();
|
||||
await useSettingsStore.getState().flushSync();
|
||||
useSettingsStore.getState().disableSync();
|
||||
}
|
||||
|
||||
|
||||
+55
-33
@@ -26,10 +26,43 @@ let syncEnabled = false;
|
||||
let syncUsername: string | null = null;
|
||||
let syncServerUrl: string | null = null;
|
||||
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingSync: SettingsSyncJob | null = null;
|
||||
let isLoadingFromServer = false;
|
||||
|
||||
const SYNC_DEBOUNCE_MS = 2000;
|
||||
|
||||
interface SettingsSyncJob {
|
||||
username: string;
|
||||
serverUrl: string;
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function syncSettingsJob(job: SettingsSyncJob, retries = 1): Promise<void> {
|
||||
syncLog('Syncing settings to server for', job.username);
|
||||
const res = await apiFetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(job),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status === 403) {
|
||||
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status >= 500 && retries > 0) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
return syncSettingsJob(job, retries - 1);
|
||||
} else if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncError('Settings sync failed:', body.error || `status ${res.status}`);
|
||||
} else {
|
||||
syncLog('Settings synced to server successfully');
|
||||
}
|
||||
}
|
||||
|
||||
export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
/** @deprecated Use Density instead */
|
||||
@@ -450,6 +483,7 @@ interface SettingsState {
|
||||
|
||||
// Settings sync
|
||||
enableSync: (username: string, serverUrl: string) => void;
|
||||
flushSync: () => Promise<void>;
|
||||
disableSync: () => void;
|
||||
loadFromServer: (username: string, serverUrl: string) => Promise<boolean>;
|
||||
}
|
||||
@@ -967,6 +1001,16 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
syncLog('Settings sync enabled for', username);
|
||||
},
|
||||
|
||||
flushSync: async () => {
|
||||
if (syncTimeout) {
|
||||
clearTimeout(syncTimeout);
|
||||
syncTimeout = null;
|
||||
}
|
||||
const job = pendingSync;
|
||||
pendingSync = null;
|
||||
if (job) await syncSettingsJob(job);
|
||||
},
|
||||
|
||||
disableSync: () => {
|
||||
syncEnabled = false;
|
||||
syncUsername = null;
|
||||
@@ -975,6 +1019,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
clearTimeout(syncTimeout);
|
||||
syncTimeout = null;
|
||||
}
|
||||
pendingSync = null;
|
||||
syncLog('Settings sync disabled');
|
||||
},
|
||||
|
||||
@@ -1189,44 +1234,21 @@ if (typeof window !== 'undefined') {
|
||||
applyDensity(store.density);
|
||||
applyAnimations(store.animationsEnabled);
|
||||
|
||||
// Shared sync function used by all store subscribers
|
||||
const syncToServer = async (retries = 1): Promise<void> => {
|
||||
const settings = JSON.parse(useSettingsStore.getState().exportSettings());
|
||||
syncLog('Syncing settings to server...');
|
||||
const res = await apiFetch('/api/settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: syncUsername, serverUrl: syncServerUrl, settings }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
syncWarn('Settings sync endpoint returned 404, disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status === 403) {
|
||||
// Identity mismatch - current session cookies don't match the
|
||||
// username/serverUrl we're syncing for (common in dev mock mode where
|
||||
// no stalwart-context cookie is written, or when rememberMe is off).
|
||||
// Retrying won't help for this session; disable to stop the noise.
|
||||
syncWarn('Settings sync rejected (identity mismatch), disabling sync');
|
||||
syncEnabled = false;
|
||||
} else if (res.status >= 500 && retries > 0) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncWarn('Settings sync got server error:', body.error || `status ${res.status}`, '- retrying...');
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
return syncToServer(retries - 1);
|
||||
} else if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
syncError('Settings sync failed:', body.error || `status ${res.status}`);
|
||||
} else {
|
||||
syncLog('Settings synced to server successfully');
|
||||
}
|
||||
};
|
||||
|
||||
const triggerSync = () => {
|
||||
if (!syncEnabled || !syncUsername || !syncServerUrl || isLoadingFromServer) return;
|
||||
pendingSync = {
|
||||
username: syncUsername,
|
||||
serverUrl: syncServerUrl,
|
||||
settings: JSON.parse(useSettingsStore.getState().exportSettings()),
|
||||
};
|
||||
if (syncTimeout) clearTimeout(syncTimeout);
|
||||
syncTimeout = setTimeout(async () => {
|
||||
syncTimeout = null;
|
||||
const job = pendingSync;
|
||||
pendingSync = null;
|
||||
if (!job) return;
|
||||
try {
|
||||
await syncToServer();
|
||||
await syncSettingsJob(job);
|
||||
} catch (error) {
|
||||
syncError('Settings sync error:', error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user