fix: let the composer send and save again in the account it was opened in

This commit is contained in:
Linus Rath
2026-10-29 02:36:09 +01:00
parent 867ef20e51
commit 388f46b5b9
4 changed files with 52 additions and 11 deletions
+1
View File
@@ -299,6 +299,7 @@
"identity_unavailable": "Identity unavailable", "identity_unavailable": "Identity unavailable",
"image_load_failed": "Could not load image", "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_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", "login_switched_title": "Account switched",
"no_recipient": "(no recipient)", "no_recipient": "(no recipient)",
"no_sent_folder": "No Sent folder found", "no_sent_folder": "No Sent folder found",
+20 -6
View File
@@ -1,20 +1,34 @@
import { describe, it, expect } from 'vitest'; 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', () => { describe('composerLoginMoved', () => {
it('stays put while the login the composer opened in is active', () => { 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. // 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', () => { 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. // 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', () => { 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);
}); });
}); });
+15 -3
View File
@@ -6,12 +6,24 @@
// login, where the same ids name other messages: an autosave or a discard // login, where the same ids name other messages: an autosave or a discard
// destroyed one of them (webmail 54b197080, d72bff95f). So the composer // destroyed one of them (webmail 54b197080, d72bff95f). So the composer
// remembers the login it was opened in and writes nothing once that changed. // 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 { export interface ComposerLogins {
/** The registry's active account now. */ /** The registry's active account now. */
activeAccountId: string | null; activeAccountId: string | null;
/** The account the JMAP client is connected to now; null while it is not connected. */ /** The login (registry id) the JMAP client serves now; null while it is not connected. */
connectedAccountId: string | null; 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 { export function composerLoginMoved(owner: string | null | undefined, now: ComposerLogins): boolean {
if (!owner) return false; if (!owner) return false;
if (now.activeAccountId !== owner) return true; if (now.activeAccountId !== owner) return true;
return now.connectedAccountId != null && now.connectedAccountId !== owner; return now.servedLogin != null && now.servedLogin !== owner;
} }
+16 -2
View File
@@ -66,7 +66,7 @@ import {
findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity, findComposeIdentityId, findDraftIdentityId, resolveComposeAccountEmail, resolveReplyIdentity,
} from '../lib/reply-identity'; } from '../lib/reply-identity';
import { shouldBlockEditorRemoteImages } from '../lib/editor-html'; import { shouldBlockEditorRemoteImages } from '../lib/editor-html';
import { composerLoginMoved } from '../lib/draft-login'; import { composerLoginMoved, servedLoginOf } from '../lib/draft-login';
import { import {
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody, initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
} from '../lib/draft-part-blobs'; } from '../lib/draft-part-blobs';
@@ -1127,7 +1127,7 @@ export default function ComposeScreen({ route, navigation }: Props) {
const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId); const ownerLoginRef = React.useRef(useAccountStore.getState().activeAccountId);
const loginMoved = () => composerLoginMoved(ownerLoginRef.current, { const loginMoved = () => composerLoginMoved(ownerLoginRef.current, {
activeAccountId: useAccountStore.getState().activeAccountId, activeAccountId: useAccountStore.getState().activeAccountId,
connectedAccountId: jmapClient.connectedAccountId, servedLogin: servedLoginOf(jmapClient),
}); });
const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle'); const [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
const [savingDraft, setSavingDraft] = React.useState(false); 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); Alert.alert(t('email_composer.save_failed', 'Failed to save'), draftUnsavableReason);
return; 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); setSavingDraft(true);
try { try {
await saveDraft({ live: true }); await saveDraft({ live: true });