From 388f46b5b96d9319079390cd8fbf25d70d586d05 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:36:09 +0100 Subject: [PATCH] fix: let the composer send and save again in the account it was opened in --- locales/rn/en.json | 1 + src/lib/__tests__/draft-login.test.ts | 26 ++++++++++++++++++++------ src/lib/draft-login.ts | 18 +++++++++++++++--- src/screens/ComposeScreen.tsx | 18 ++++++++++++++++-- 4 files changed, 52 insertions(+), 11 deletions(-) diff --git a/locales/rn/en.json b/locales/rn/en.json index 0bf4dc5c..a1042d59 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -299,6 +299,7 @@ "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_save_body": "You started this message in {account}. Switch back to that account to save it.", "login_switched_title": "Account switched", "no_recipient": "(no recipient)", "no_sent_folder": "No Sent folder found", diff --git a/src/lib/__tests__/draft-login.test.ts b/src/lib/__tests__/draft-login.test.ts index ff95c5f2..d3503db8 100644 --- a/src/lib/__tests__/draft-login.test.ts +++ b/src/lib/__tests__/draft-login.test.ts @@ -1,20 +1,34 @@ import { describe, it, expect } from 'vitest'; -import { composerLoginMoved } from '../draft-login'; +import { composerLoginMoved, servedLoginOf } from '../draft-login'; +import { generateAccountId } from '../account-utils'; + +// Real formats: the registry names logins `username@host`, the session names +// JMAP accounts with short ids that never equal a registry id. +const A = generateAccountId('usera@example.org', 'https://mail.example.org'); +const B = generateAccountId('userb@example.org', 'https://other.example.net'); +const client = (username: string, serverUrl: string, isConnected = true) => ({ username, serverUrl, isConnected }); + +describe('servedLoginOf', () => { + it('names the login a client serves by its registry id, not its JMAP account id', () => { + expect(servedLoginOf(client('usera@example.org', 'https://mail.example.org'))).toBe(A); + expect(servedLoginOf(client('usera@example.org', 'https://mail.example.org', false))).toBeNull(); + }); +}); 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); + expect(composerLoginMoved(A, { activeAccountId: A, servedLogin: servedLoginOf(client('usera@example.org', 'https://mail.example.org')) })).toBe(false); // Offline cold start: the client is not connected yet. - expect(composerLoginMoved('acc-a', { activeAccountId: 'acc-a', connectedAccountId: null })).toBe(false); + expect(composerLoginMoved(A, { activeAccountId: A, servedLogin: 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); + expect(composerLoginMoved(A, { activeAccountId: B, servedLogin: 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); + expect(composerLoginMoved(A, { activeAccountId: A, servedLogin: servedLoginOf(client('userb@example.org', 'https://other.example.net')) })).toBe(true); }); it('never blocks a composer opened without a login', () => { - expect(composerLoginMoved(null, { activeAccountId: 'acc-b', connectedAccountId: 'acc-b' })).toBe(false); + expect(composerLoginMoved(null, { activeAccountId: B, servedLogin: B })).toBe(false); }); }); diff --git a/src/lib/draft-login.ts b/src/lib/draft-login.ts index e498a4c7..224d1a24 100644 --- a/src/lib/draft-login.ts +++ b/src/lib/draft-login.ts @@ -6,12 +6,24 @@ // 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. +// +// Logins are registry ids (`username@host`, lib/account-utils). The JMAP +// account id the session names ('b', 'c', ...) is another thing: it repeats +// across servers and never equals a registry id. + +import { generateAccountId } from './account-utils'; 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; + /** The login (registry id) the JMAP client serves now; null while it is not connected. */ + servedLogin: string | null; +} + +/** The registry id of the login a client serves, or null while it serves none. */ +export function servedLoginOf(client: { isConnected: boolean; username: string | null; serverUrl: string | null }): string | null { + if (!client.isConnected || !client.username || !client.serverUrl) return null; + return generateAccountId(client.username, client.serverUrl); } /** @@ -22,5 +34,5 @@ export interface ComposerLogins { 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; + return now.servedLogin != null && now.servedLogin !== owner; } diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index fe4faadd..37798e8c 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -66,7 +66,7 @@ import { findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, } from '../lib/reply-identity'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; -import { composerLoginMoved } from '../lib/draft-login'; +import { composerLoginMoved, servedLoginOf } from '../lib/draft-login'; import { initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, } from '../lib/draft-part-blobs'; @@ -1127,7 +1127,7 @@ export default function ComposeScreen({ route, navigation }: Props) { const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId); const loginMoved = () => composerLoginMoved(ownerLoginRef.current, { activeAccountId: useAccountStore.getState().activeAccountId, - connectedAccountId: jmapClient.connectedAccountId, + servedLogin: servedLoginOf(jmapClient), }); const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [savingDraft, setSavingDraft] = React.useState(false); @@ -1443,6 +1443,20 @@ export default function ComposeScreen({ route, navigation }: Props) { Alert.alert(t('email_composer.save_failed', 'Failed to save'), draftUnsavableReason); return; } + // Another login is active: saving writes nothing (saveDraftOnce), so + // closing now would drop the text as if it had been saved. + 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_save_body', + 'You started this message in {account}. Switch back to that account to save it.', + { account: owner?.email || owner?.displayName || '' }, + ), + ); + return; + } setSavingDraft(true); try { await saveDraft({ live: true });