diff --git a/locales/rn/en.json b/locales/rn/en.json index b5ad94de..3b00ff73 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -762,6 +762,7 @@ "cancelled_not_moved_body": "The message will not be sent. It is still in Sent.", "edit_failed": "Could not open the message for editing", "keep_scheduled": "Keep scheduled", + "login_switched": "These scheduled messages belong to another account. Switch back to it to change them.", "no_longer_scheduled": "This message is no longer scheduled.", "reschedule_failed": "Reschedule failed", "reschedule_lost": "The message is no longer scheduled. Check Drafts and Sent before sending it again.", diff --git a/src/lib/__tests__/scheduled-screen-wiring.test.ts b/src/lib/__tests__/scheduled-screen-wiring.test.ts index 9b1c8887..eabe86b8 100644 --- a/src/lib/__tests__/scheduled-screen-wiring.test.ts +++ b/src/lib/__tests__/scheduled-screen-wiring.test.ts @@ -23,4 +23,23 @@ describe('Scheduled screen', () => { expect(handled).toMatch(/if \(moveError\) \{/); } }); + + // The list loaded once, on mount: after a deep-link switch of logins it + // still showed the first login's rows, and Cancel or Send now acted on the + // new login's submissions with the same ids. + it('remembers the login the rows were read for and acts on them only while it is active', () => { + const load = source.slice(source.indexOf('const load = React.useCallback(')); + expect(load).toMatch(/^const load = React\.useCallback\(async \(\) => \{[\s\S]*?const owner = servedLoginOf\(jmapClient\);[\s\S]*?await listScheduledEmails\(\);[\s\S]*?if \(loginMovedFrom\(owner\)\) return;\s+ownerRef\.current = owner;\s+setItems\(list\);/); + expect(body('runAction')).toMatch(/^const runAction = async [^\n]*\{\s+if \(refuseOtherLogin\(\)\) return;/); + expect(source).toMatch(/const openView = React\.useCallback\(\(item: ScheduledEmail\) => \{\s+if \(refuseOtherLogin\(\)\) return;/); + expect(source).toMatch(/if \(!loginMovedFrom\(ownerRef\.current\)\) return false;/); + }); + + it('reads the list again for every session and drops the rows of another login', () => { + expect(source).toMatch(/if \(ownerRef\.current !== null && loginMovedFrom\(ownerRef\.current\)\) \{[\s\S]*?setItems\(\[\]\);[\s\S]*?void load\(\);\s+\}, \[activeAccountId, session, load\]\);/); + }); + + it('closes the view of a message that is no longer listed', () => { + expect(source).toMatch(/if \(viewing && !items\.some\(\(i\) => sameScheduledSend\(i, viewing\)\)\) closeView\(\);\s+\}, \[items, viewing, closeView\]\);/); + }); }); diff --git a/src/screens/ScheduledScreen.tsx b/src/screens/ScheduledScreen.tsx index 62c9bedf..4a406f98 100644 --- a/src/screens/ScheduledScreen.tsx +++ b/src/screens/ScheduledScreen.tsx @@ -30,6 +30,9 @@ import { isPendingSend, sameScheduledSend, scheduledSendKey } from '../lib/sched import { rescheduleErrorNotice, rescheduleLostMessage, restoredSubmission } from '../lib/compose-reschedule'; import { cancelScheduledToDrafts, scheduledAccountOverride } from '../lib/compose-scheduled'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; +import { loginMovedFrom } from '../lib/compose-login'; +import { servedLoginOf } from '../lib/draft-login'; +import { useAuthStore } from '../stores/auth-store'; import { ScheduledMessageView } from '../components/compose/ScheduledMessageView'; import type { Email } from '../api/types'; @@ -56,7 +59,23 @@ export default function ScheduledScreen({ navigation }: Props) { const [viewEmail, setViewEmail] = React.useState(null); const [viewError, setViewError] = React.useState(null); const viewSeq = React.useRef(0); + // The login the rows were read for. Submission and email ids repeat across + // logins: after a switch (a notification or link for another account) the + // rows on screen are the old login's, and acting on them would cancel or + // send whatever the new one has under the same ids. + const ownerRef = React.useRef(null); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const session = useAuthStore((s) => s.session); + const refuseOtherLogin = React.useCallback(() => { + if (!loginMovedFrom(ownerRef.current)) return false; + Alert.alert( + t('email_composer.login_switched_title', 'Account switched'), + t('scheduled.login_switched', 'These scheduled messages belong to another account. Switch back to it to change them.'), + ); + return true; + }, [t]); const openView = React.useCallback((item: ScheduledEmail) => { + if (refuseOtherLogin()) return; const seq = ++viewSeq.current; setViewing(item); setViewEmail(null); @@ -65,7 +84,7 @@ export default function ScheduledScreen({ navigation }: Props) { (email) => { if (viewSeq.current === seq) setViewEmail(email); }, (e: unknown) => { if (viewSeq.current === seq) setViewError(e instanceof Error ? e.message : String(e)); }, ); - }, []); + }, [refuseOtherLogin]); const closeView = React.useCallback(() => { viewSeq.current += 1; setViewing(null); @@ -87,23 +106,49 @@ export default function ScheduledScreen({ navigation }: Props) { return extra > 0 ? `${name} +${extra}` : name; }; + // Only the latest read is shown: one started before a switch of logins may + // finish after the next one. + const loadSeq = React.useRef(0); const load = React.useCallback(async () => { + const seq = ++loadSeq.current; + const owner = servedLoginOf(jmapClient); setLoading(true); setError(null); try { - setItems(await listScheduledEmails()); + const list = await listScheduledEmails(); + if (seq !== loadSeq.current) return; + // Switched during the read: the next session reads again. + if (loginMovedFrom(owner)) return; + ownerRef.current = owner; + setItems(list); } catch (e) { - setError(e instanceof Error ? e.message : t('email_list.error', 'Error')); + if (seq === loadSeq.current) setError(e instanceof Error ? e.message : t('email_list.error', 'Error')); } finally { - setLoading(false); + if (seq === loadSeq.current) setLoading(false); } }, [t]); + // Read on mount and again for every session; rows, the open view and the + // menus of another login go at once. React.useEffect(() => { + if (ownerRef.current !== null && loginMovedFrom(ownerRef.current)) { + ownerRef.current = null; + setItems([]); + setActionsFor(null); + setRescheduleFor(null); + setCustomStage(null); + } void load(); - }, [load]); + }, [activeAccountId, session, load]); + + // A message that is gone from the list (sent, cancelled, read again after + // a switch) closes its view. + React.useEffect(() => { + if (viewing && !items.some((i) => sameScheduledSend(i, viewing))) closeView(); + }, [items, viewing, closeView]); const runAction = async (item: ScheduledEmail, action: () => Promise, failTitle: string) => { + if (refuseOtherLogin()) return; setBusyId(scheduledSendKey(item)); try { await action();