From 9daa3441c80f14327a35da9cc98cae6080bd2b6f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:15:14 +0100 Subject: [PATCH] fix: keep a composer left open across an account switch from saving or deleting drafts in the other account --- locales/rn/en.json | 2 ++ src/lib/__tests__/draft-login.test.ts | 20 ++++++++++++++++++++ src/lib/draft-login.ts | 26 ++++++++++++++++++++++++++ src/screens/ComposeScreen.tsx | 24 +++++++++++++++++++++++- 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/lib/__tests__/draft-login.test.ts create mode 100644 src/lib/draft-login.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index f81aa537..795f6f3a 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -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.", diff --git a/src/lib/__tests__/draft-login.test.ts b/src/lib/__tests__/draft-login.test.ts new file mode 100644 index 00000000..ff95c5f2 --- /dev/null +++ b/src/lib/__tests__/draft-login.test.ts @@ -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); + }); +}); diff --git a/src/lib/draft-login.ts b/src/lib/draft-login.ts new file mode 100644 index 00000000..e498a4c7 --- /dev/null +++ b/src/lib/draft-login.ts @@ -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; +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 9105cee5..fe4faadd 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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 => { 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