fix: keep a composer left open across an account switch from saving or deleting drafts in the other account

This commit is contained in:
Linus Rath
2026-10-29 02:15:14 +01:00
parent 15171637cd
commit 9daa3441c8
4 changed files with 71 additions and 1 deletions
+2
View File
@@ -298,6 +298,8 @@
"group_chip": "{name} ({count})",
"identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image",
"login_switched_body": "You started this message in {account}. Switch back to that account to send it.",
"login_switched_title": "Account switched",
"no_recipient": "(no recipient)",
"no_sent_folder": "No Sent folder found",
"permission_camera": "Camera permission is required to take a photo.",
+20
View File
@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { composerLoginMoved } from '../draft-login';
describe('composerLoginMoved', () => {
it('stays put while the login the composer opened in is active', () => {
expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: 'acc-a' })).toBe(false);
// Offline cold start: the client is not connected yet.
expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: null })).toBe(false);
});
it('moves once another login is active or the client serves another one', () => {
expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-b', connectedAccountId: 'acc-b' })).toBe(true);
// Mid-switch: the registry still names A, the client already serves B.
expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: 'acc-b' })).toBe(true);
});
it('never blocks a composer opened without a login', () => {
expect(composerLoginMoved(null, { activeAccountId: 'acc-b', connectedAccountId: 'acc-b' })).toBe(false);
});
});
+26
View File
@@ -0,0 +1,26 @@
// The composer writes through the active login: autosave creates a version
// and destroys the previous one by id, Discard destroys the autosaved draft,
// Send flags the original `$answered`. A notification or permalink for
// another account can switch logins while the composer stays open under the
// message it opens. Back in the composer, those writes went to the other
// login, where the same ids name other messages: an autosave or a discard
// destroyed one of them (webmail 54b197080, d72bff95f). So the composer
// remembers the login it was opened in and writes nothing once that changed.
export interface ComposerLogins {
/** The registry's active account now. */
activeAccountId: string | null;
/** The account the JMAP client is connected to now; null while it is not connected. */
connectedAccountId: string | null;
}
/**
* Whether the composer opened in `owner` would now write to another login.
* A client that is not connected yet (a cold start offline) still counts as
* the owner's while the registry says so: its writes cannot go anywhere else.
*/
export function composerLoginMoved(owner: string | null | undefined, now: ComposerLogins): boolean {
if (!owner) return false;
if (now.activeAccountId !== owner) return true;
return now.connectedAccountId != null && now.connectedAccountId !== owner;
}
+23 -1
View File
@@ -66,6 +66,7 @@ import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import { composerLoginMoved } from '../lib/draft-login';
import {
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
} from '../lib/draft-part-blobs';
@@ -1122,6 +1123,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
const lastSaveAtRef = React.useRef(0);
const allowLeaveRef = React.useRef(false);
const sendingRef = React.useRef(false);
// The login this message was started in (see lib/draft-login).
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
const loginMoved = () => composerLoginMoved(ownerLoginRef.current, {
activeAccountId: useAccountStore.getState().activeAccountId,
connectedAccountId: jmapClient.connectedAccountId,
});
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false);
@@ -1336,6 +1343,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
const saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
const identity = primaryIdentity;
if (!identity || !draftsMailbox || sendingRef.current) return draftIdRef.current;
// Another login is active: its Drafts and ids are not this draft's.
if (loginMoved()) return draftIdRef.current;
let html = latestRef.current.bodyHtml;
if (opts.live && !plainTextMode) {
try {
@@ -1460,7 +1469,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
// A draft this session created by autosave goes with the discard; a
// re-opened draft keeps its last saved version.
const autosaved = draftIdRef.current;
if (autosaved && !draft) {
// In another login that id names another message; the draft stays.
if (autosaved && !draft && !loginMoved()) {
void (async () => {
try { await inflightSaveRef.current; } catch { /* ignore */ }
const id = draftIdRef.current ?? autosaved;
@@ -2109,6 +2119,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
if (!primaryIdentity || !sentMailbox) return;
if (loginMoved()) {
const owner = ownerLoginRef.current ? useAccountStore.getState().getAccountById(ownerLoginRef.current) : undefined;
Alert.alert(
t('email_composer.login_switched_title', 'Account switched'),
t(
'email_composer.login_switched_body',
'You started this message in {account}. Switch back to that account to send it.',
{ account: owner?.email || owner?.displayName || '' },
),
);
return;
}
// Read the body straight from the editor DOM at send time. The `change`
// messages that feed `bodyHtml` are async and best-effort — trusting them