diff --git a/src/lib/__tests__/scheduled-send-key.test.ts b/src/lib/__tests__/scheduled-send-key.test.ts new file mode 100644 index 00000000..9b0afb2b --- /dev/null +++ b/src/lib/__tests__/scheduled-send-key.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; +import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../scheduled-send-key'; + +// Stalwart numbers each account's submissions from the start, so the own +// account and a group account both hold an "a". +const own = { accountId: 'own', emailSubmissionId: 'a' }; +const group = { accountId: 'grp', emailSubmissionId: 'a' }; + +describe('scheduled send identity', () => { + it('tells apart records of two accounts with the same id', () => { + expect(scheduledSendKey(own)).not.toBe(scheduledSendKey(group)); + expect(sameScheduledSend(own, group)).toBe(false); + expect(sameScheduledSend(own, { ...own })).toBe(true); + }); + + it('matches the undo snackbar only to its own account\'s row', () => { + // A send from the composer names no account: it is the own one. + expect(isPendingSend({ emailSubmissionId: 'a' }, own, 'own')).toBe(true); + expect(isPendingSend({ emailSubmissionId: 'a' }, group, 'own')).toBe(false); + expect(isPendingSend({ emailSubmissionId: 'a', accountId: 'grp' }, group, 'own')).toBe(true); + expect(isPendingSend(null, own, 'own')).toBe(false); + }); +}); diff --git a/src/lib/scheduled-send-key.ts b/src/lib/scheduled-send-key.ts new file mode 100644 index 00000000..f34b9359 --- /dev/null +++ b/src/lib/scheduled-send-key.ts @@ -0,0 +1,32 @@ +// Scheduled sends of the own and a group or shared account are listed +// together, and submission ids repeat across accounts (Stalwart numbers each +// account's records from the start). So a row is the pair of account and +// submission id: by the id alone, cancelling or rescheduling one account's +// message also took the other's row off the list or rewrote it, and the undo +// snackbar of one was cleared by an action on the other (webmail e314e5666). + +import type { ScheduledEmail } from '../api/email'; + +type Keyed = Pick; + +/** The list key of a scheduled send. */ +export function scheduledSendKey(item: Keyed): string { + return `${item.accountId}|${item.emailSubmissionId}`; +} + +/** Whether two scheduled sends are the same record. */ +export function sameScheduledSend(a: Keyed, b: Keyed): boolean { + return a.accountId === b.accountId && a.emailSubmissionId === b.emailSubmissionId; +} + +/** + * Whether the undo snackbar's pending send is this row. The pending send + * names no account for a send from the own account (`ownAccountId`). + */ +export function isPendingSend( + pending: { emailSubmissionId: string; accountId?: string } | null | undefined, + item: Keyed, + ownAccountId: string, +): boolean { + return !!pending && sameScheduledSend({ accountId: pending.accountId ?? ownAccountId, emailSubmissionId: pending.emailSubmissionId }, item); +} diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index d1991ce5..9b74e6d2 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -29,6 +29,7 @@ import { formatQuoteDate } from '../lib/quote-header'; import { spacing, typography, componentSizes, radius, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; +import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/scheduled-send-key'; type Props = NativeStackScreenProps; @@ -79,7 +80,7 @@ export default function ScheduledScreen({ navigation }: Props) { }, [load]); const runAction = async (item: ScheduledEmail, action: () => Promise, failTitle: string) => { - setBusyId(item.emailSubmissionId); + setBusyId(scheduledSendKey(item)); try { await action(); } catch (e) { @@ -124,7 +125,11 @@ export default function ScheduledScreen({ navigation }: Props) { toast.warning(title, { message, duration: 10_000 }); }; - const removeItem = (id: string) => setItems((prev) => prev.filter((i) => i.emailSubmissionId !== id)); + // Submission ids repeat across accounts: a row is its account and id. + const removeItem = (item: ScheduledEmail) => setItems((prev) => prev.filter((i) => !sameScheduledSend(i, item))); + const clearUndoFor = (item: ScheduledEmail) => { + if (isPendingSend(useSendUndoStore.getState().pending, item, jmapClient.accountId)) useSendUndoStore.getState().clear(); + }; const onCancel = (item: ScheduledEmail) => { Alert.alert( @@ -138,10 +143,8 @@ export default function ScheduledScreen({ navigation }: Props) { onPress: () => { void runAction(item, async () => { await cancelScheduledSend(item.emailSubmissionId, item.accountId); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } - removeItem(item.emailSubmissionId); + clearUndoFor(item); + removeItem(item); }, t('scheduled.cancel_failed', 'Cancel failed')); }, }, @@ -152,10 +155,8 @@ export default function ScheduledScreen({ navigation }: Props) { const onSendNow = (item: ScheduledEmail) => { void runAction(item, async () => { const result = await rescheduleScheduledSend(item, 0); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } - removeItem(item.emailSubmissionId); + clearUndoFor(item); + removeItem(item); warnRefused(result.refusedRecipients); }, t('scheduled.send_now_failed', 'Could not send now')); }; @@ -179,10 +180,9 @@ export default function ScheduledScreen({ navigation }: Props) { } void runAction(item, async () => { const result = await rescheduleScheduledSend(item, seconds); - const pending = useSendUndoStore.getState().pending; - if (pending?.emailSubmissionId === item.emailSubmissionId) useSendUndoStore.getState().clear(); + clearUndoFor(item); setItems((prev) => prev - .map((i) => (i.emailSubmissionId === item.emailSubmissionId + .map((i) => (sameScheduledSend(i, item) ? { ...i, emailSubmissionId: result.emailSubmissionId ?? i.emailSubmissionId, sendAt: result.sendAt ?? date.toISOString() } : i)) .sort((a, b) => new Date(a.sendAt).getTime() - new Date(b.sendAt).getTime())); @@ -195,9 +195,7 @@ export default function ScheduledScreen({ navigation }: Props) { const onEdit = (item: ScheduledEmail) => { void runAction(item, async () => { await cancelScheduledSend(item.emailSubmissionId, item.accountId); - if (useSendUndoStore.getState().pending?.emailSubmissionId === item.emailSubmissionId) { - useSendUndoStore.getState().clear(); - } + clearUndoFor(item); // A message held in a shared account goes back to that account's Drafts. const shared = item.accountId !== jmapClient.accountId; const scope = shared @@ -214,7 +212,7 @@ export default function ScheduledScreen({ navigation }: Props) { ); } const email = await getFullEmail(item.emailId, item.accountId); - removeItem(item.emailSubmissionId); + removeItem(item); navigation.replace('Compose', { draft: draftContextFromEmail(email, shared ? item.accountId : undefined) }); }, t('scheduled.edit_failed', 'Could not open the message for editing')); }; @@ -273,7 +271,7 @@ export default function ScheduledScreen({ navigation }: Props) { }; const renderItem = ({ item }: { item: ScheduledEmail }) => { - const busy = busyId === item.emailSubmissionId; + const busy = busyId === scheduledSendKey(item); return ( setActionsFor(item)} disabled={busy}> @@ -347,7 +345,7 @@ export default function ScheduledScreen({ navigation }: Props) { ) : ( i.emailSubmissionId} + keyExtractor={scheduledSendKey} renderItem={renderItem} ItemSeparatorComponent={() => } // A stack screen has no tab bar: the list ends clear of the gesture bar.