mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 08:06:36 +00:00
fix: keep a composer left open across an account switch from saving or deleting drafts in the other account
This commit is contained in:
@@ -298,6 +298,8 @@
|
|||||||
"group_chip": "{name} ({count})",
|
"group_chip": "{name} ({count})",
|
||||||
"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_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",
|
||||||
"permission_camera": "Camera permission is required to take a photo.",
|
"permission_camera": "Camera permission is required to take a photo.",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -66,6 +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 {
|
import {
|
||||||
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
initialDraftPartBlobState, liveBlobId, rebaseDraftPartBlobs, withSnapshotBody,
|
||||||
} from '../lib/draft-part-blobs';
|
} from '../lib/draft-part-blobs';
|
||||||
@@ -1122,6 +1123,12 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
const lastSaveAtRef = React.useRef(0);
|
const lastSaveAtRef = React.useRef(0);
|
||||||
const allowLeaveRef = React.useRef(false);
|
const allowLeaveRef = React.useRef(false);
|
||||||
const sendingRef = 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 [draftStatus, setDraftStatus] = React.useState<'idle' | 'saving' | 'saved' | 'failed'>('idle');
|
||||||
const [savingDraft, setSavingDraft] = React.useState(false);
|
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 saveDraftOnce = async (opts: { live: boolean }): Promise<string | null> => {
|
||||||
const identity = primaryIdentity;
|
const identity = primaryIdentity;
|
||||||
if (!identity || !draftsMailbox || sendingRef.current) return draftIdRef.current;
|
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;
|
let html = latestRef.current.bodyHtml;
|
||||||
if (opts.live && !plainTextMode) {
|
if (opts.live && !plainTextMode) {
|
||||||
try {
|
try {
|
||||||
@@ -1460,7 +1469,8 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
// A draft this session created by autosave goes with the discard; a
|
// A draft this session created by autosave goes with the discard; a
|
||||||
// re-opened draft keeps its last saved version.
|
// re-opened draft keeps its last saved version.
|
||||||
const autosaved = draftIdRef.current;
|
const autosaved = draftIdRef.current;
|
||||||
if (autosaved && !draft) {
|
// In another login that id names another message; the draft stays.
|
||||||
|
if (autosaved && !draft && !loginMoved()) {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try { await inflightSaveRef.current; } catch { /* ignore */ }
|
try { await inflightSaveRef.current; } catch { /* ignore */ }
|
||||||
const id = draftIdRef.current ?? autosaved;
|
const id = draftIdRef.current ?? autosaved;
|
||||||
@@ -2109,6 +2119,18 @@ export default function ComposeScreen({ route, navigation }: Props) {
|
|||||||
|
|
||||||
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
const performSendInner = async (holdForSeconds?: number, scheduledAt?: Date) => {
|
||||||
if (!primaryIdentity || !sentMailbox) return;
|
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`
|
// Read the body straight from the editor DOM at send time. The `change`
|
||||||
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
// messages that feed `bodyHtml` are async and best-effort — trusting them
|
||||||
|
|||||||
Reference in New Issue
Block a user