fix: reload Scheduled after a switch of accounts and refuse actions on the other account's rows

This commit is contained in:
Linus Rath
2026-10-29 05:01:12 +01:00
parent 51b3208b3b
commit c817f978a4
3 changed files with 70 additions and 5 deletions
+1
View File
@@ -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.",
@@ -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\]\);/);
});
});
+50 -5
View File
@@ -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<Email | null>(null);
const [viewError, setViewError] = React.useState<string | null>(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<string | null>(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<void>, failTitle: string) => {
if (refuseOtherLogin()) return;
setBusyId(scheduledSendKey(item));
try {
await action();