fix: let a composer opened offline send from the last known identities

This commit is contained in:
Linus Rath
2026-10-31 20:15:59 +01:00
parent ba67e67339
commit 93de202b38
3 changed files with 131 additions and 7 deletions
+42
View File
@@ -0,0 +1,42 @@
// A composer opened offline showed "Identity unavailable" with Send off, and
// stayed so once back online until it was closed and opened again.
import { describe, expect, it, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Identity } from '../../api/types';
import { loadComposerIdentities, readCachedIdentities } from '../identity-cache';
const me = { id: 'i1', name: 'User A', email: 'usera@example.org', mayDelete: false } as Identity;
const offline = () => Promise.reject(new Error('Network request failed'));
beforeEach(async () => {
await AsyncStorage.clear();
});
describe('composer identities', () => {
it('keeps what the server said for the login', async () => {
const result = await loadComposerIdentities('usera@host', async () => [me]);
expect(result).toEqual({ identities: [me], fresh: true, error: null });
expect(await readCachedIdentities('usera@host')).toEqual([me]);
expect(await readCachedIdentities('other@host')).toEqual([]);
});
it('uses the kept identities when the server cannot be reached', async () => {
await loadComposerIdentities('usera@host', async () => [me]);
const result = await loadComposerIdentities('usera@host', offline);
expect(result).toEqual({ identities: [me], fresh: false, error: null });
});
it('reports the error only when nothing was kept', async () => {
const result = await loadComposerIdentities('usera@host', offline);
expect(result.identities).toEqual([]);
expect(result.error).toBe('Network request failed');
});
it('is read again by the composer when the connection or the session comes back', () => {
const src = fs.readFileSync(path.resolve(__dirname, '../../screens/ComposeScreen.tsx'), 'utf8');
expect(src).toMatch(/loadComposerIdentities\(login, \(\) => getIdentities\(\)\)/);
expect(src).toMatch(/\}, \[online, sessionUp, identitiesFresh\]\);/);
});
});
+63
View File
@@ -0,0 +1,63 @@
// The composer's sender identities when the server can't be asked. A
// composer opened offline showed From "Identity unavailable" with Send off,
// and stayed so after the connection came back until it was closed and
// opened again. It now starts from the identities last read for the login
// and reads them again once it can; the Outbox sends the message later.
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { Identity } from '../api/types';
const KEY_PREFIX = 'compose:identities:v1:';
function keyFor(localAccountId: string): string {
return `${KEY_PREFIX}${localAccountId}`;
}
/** The identities last read for the login, or [] when none were kept. */
export async function readCachedIdentities(localAccountId: string | null | undefined): Promise<Identity[]> {
if (!localAccountId) return [];
try {
const raw = await AsyncStorage.getItem(keyFor(localAccountId));
const parsed = raw ? (JSON.parse(raw) as unknown) : null;
return Array.isArray(parsed)
? parsed.filter((i): i is Identity => !!i && typeof i === 'object' && typeof (i as Identity).id === 'string')
: [];
} catch {
return [];
}
}
/** Keep what the server just said, for the next composer opened offline. */
export async function writeCachedIdentities(localAccountId: string | null | undefined, identities: Identity[]): Promise<void> {
if (!localAccountId) return;
try {
await AsyncStorage.setItem(keyFor(localAccountId), JSON.stringify(identities));
} catch { /* best effort */ }
}
export interface ComposerIdentities {
identities: Identity[];
/** The server's answer; false while they are only the kept copy. */
fresh: boolean;
/** Why neither the server nor a kept copy gave any. */
error: string | null;
}
/**
* The server's identities, kept for later; the kept copy when the server
* can't be reached. An error only when there is neither.
*/
export async function loadComposerIdentities(
localAccountId: string | null | undefined,
fetchIdentities: () => Promise<Identity[]>,
): Promise<ComposerIdentities> {
try {
const identities = await fetchIdentities();
if (identities.length > 0) await writeCachedIdentities(localAccountId, identities);
return { identities, fresh: true, error: null };
} catch (e) {
const cached = await readCachedIdentities(localAccountId);
if (cached.length > 0) return { identities: cached, fresh: false, error: null };
return { identities: [], fresh: false, error: e instanceof Error ? e.message : 'Failed to load identities' };
}
}
+26 -7
View File
@@ -44,6 +44,7 @@ import { useSendUndoStore } from '../stores/send-undo-store';
import { toast } from '../stores/toast-store';
import { type EmailTemplate } from '../stores/templates-store';
import { getIdentities } from '../api/identity';
import { loadComposerIdentities, readCachedIdentities } from '../lib/identity-cache';
import {
sendEmail, saveDraftVersion, destroyEmails, patchKeywordsForEmails, SendUnconfirmedError,
type OutgoingAttachment, type OutgoingEmail,
@@ -96,6 +97,7 @@ import { RecipientPicker } from '../components/compose/RecipientPicker';
import { isPickerReopenTooSoon, needsRecipientPicker } from '../lib/recipient-picker';
import { useOutboxStore } from '../stores/outbox-store';
import { useNetworkStore } from '../stores/network-store';
import { useAuthStore } from '../stores/auth-store';
import { isTransientNetworkError } from '../lib/network-error';
import { classifySendFailure, shouldQueueFailedSend } from '../lib/send-queue';
import {
@@ -811,20 +813,37 @@ export default function ComposeScreen({ route, navigation }: Props) {
inputSetterFor(field)('');
};
// The identities last read for this login stand in while the server can't
// be asked (offline, or the session not up yet), so From and Send work and
// the message waits in the Outbox. Read again once back online, or once
// the session is up.
const online = useNetworkStore((s) => s.online);
const sessionUp = useAuthStore((s) => !!s.session);
const [identitiesFresh, setIdentitiesFresh] = React.useState(false);
React.useEffect(() => {
if (identitiesFresh) return;
let cancelled = false;
const login = useAccountStore.getState().activeAccountId;
void (async () => {
try {
const list = await getIdentities();
if (!cancelled) setIdentities(list);
} catch (e) {
if (!cancelled) {
setIdentityError(e instanceof Error ? e.message : 'Failed to load identities');
if (!online) {
const cached = await readCachedIdentities(login);
if (!cancelled && cached.length > 0) {
setIdentities((prev) => (prev.length > 0 ? prev : cached));
setIdentityError(null);
}
}
const result = await loadComposerIdentities(login, () => getIdentities());
if (cancelled) return;
if (result.identities.length > 0 || result.fresh) {
setIdentities(result.identities);
setIdentityError(null);
} else if (result.error) {
setIdentityError(result.error);
}
setIdentitiesFresh(result.fresh);
})();
return () => { cancelled = true; };
}, []);
}, [online, sessionUp, identitiesFresh]);
// Once the identities are known, recompute the reply recipients so every
// own alias is dropped from a reply-all (the initial seed only knew the