mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: let a composer opened offline send from the last known identities
This commit is contained in:
@@ -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\]\);/);
|
||||
});
|
||||
});
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user