diff --git a/src/lib/__tests__/identity-cache.test.ts b/src/lib/__tests__/identity-cache.test.ts new file mode 100644 index 00000000..9bd5010f --- /dev/null +++ b/src/lib/__tests__/identity-cache.test.ts @@ -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\]\);/); + }); +}); diff --git a/src/lib/identity-cache.ts b/src/lib/identity-cache.ts new file mode 100644 index 00000000..597c1c55 --- /dev/null +++ b/src/lib/identity-cache.ts @@ -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 { + 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 { + 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, +): Promise { + 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' }; + } +} diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index 18a25ae2..737e5c1f 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -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