mirror of
https://github.com/bulwarkmail/native.git
synced 2026-10-31 16:06:32 +00:00
fix: say a message deleted elsewhere is gone instead of failing to load it
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('../../api/jmap-client', () => ({
|
||||
jmapClient: { accountId: 'own', serverUrl: 'https://mail.example', username: 'me' },
|
||||
@@ -15,7 +17,7 @@ import { getEmailFlags, getFullEmailsWithState, getThreadsHeaders } from '../../
|
||||
import { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||
import { dispatchStateChange } from '../state-change-bus';
|
||||
import {
|
||||
clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
||||
clearEmailDetailCache, EmailNotFoundError, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
||||
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
|
||||
} from '../email-detail-cache';
|
||||
|
||||
@@ -158,6 +160,31 @@ describe('loading a message', () => {
|
||||
expect(peekDetail('e1')).toBeUndefined();
|
||||
});
|
||||
|
||||
// A row deleted elsewhere opened "Failed to load email": the viewer could
|
||||
// not tell a missing message from a failed load.
|
||||
it('tells a message the server no longer has from a failed load', async () => {
|
||||
fullGet.mockResolvedValueOnce(fullResponse([], 's1', ['e9']));
|
||||
await expect(loadDetail('e9')).rejects.toBeInstanceOf(EmailNotFoundError);
|
||||
|
||||
fullGet.mockResolvedValueOnce(fullResponse([full('e1')], 's1'));
|
||||
await loadDetail('e1');
|
||||
noteEmailState(undefined, 's2');
|
||||
flagsGet.mockResolvedValueOnce({ list: [], notFound: ['e1'], state: 's2' });
|
||||
await expect(loadDetail('e1')).rejects.toBeInstanceOf(EmailNotFoundError);
|
||||
|
||||
fullGet.mockRejectedValueOnce(new TypeError('Network request failed'));
|
||||
const failed = await loadDetail('e3').catch((err: unknown) => err);
|
||||
expect(failed).not.toBeInstanceOf(EmailNotFoundError);
|
||||
});
|
||||
|
||||
it('has the viewer say a gone message is no longer available and refresh the list', () => {
|
||||
const viewer = fs.readFileSync(path.resolve(__dirname, '..', '..', 'screens/EmailThreadScreen.tsx'), 'utf8');
|
||||
expect(viewer).toMatch(/if \(err instanceof EmailNotFoundError\) onGone\?\.\(\);/);
|
||||
expect(viewer).toMatch(/const fetched = await ensureDetail\(activeEmailId, \(\) => \{ gone = true; \}\);/);
|
||||
expect(viewer).toMatch(/t\('deep_link\.message_not_found', 'This message is no longer available\.'\)/);
|
||||
expect(viewer).toMatch(/if \(gone\) void useEmailStore\.getState\(\)\.refreshEmails\(\);/);
|
||||
});
|
||||
|
||||
it('rejects with the fetch error when nothing is held', async () => {
|
||||
fullGet.mockRejectedValue(new Error('boom'));
|
||||
await expect(loadDetail('e1')).rejects.toThrow('boom');
|
||||
|
||||
@@ -177,6 +177,17 @@ function localSince(key: string, startedAt: number): FlagsHint | undefined {
|
||||
return local && local.at > startedAt ? local.patch : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The server has no such message (deleted or moved out of reach elsewhere),
|
||||
* as opposed to a load that failed: the viewer says it is gone.
|
||||
*/
|
||||
export class EmailNotFoundError extends Error {
|
||||
constructor(id: string) {
|
||||
super(`Email ${id} not found`);
|
||||
this.name = 'EmailNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/** The copy held for a message, if any. Synchronous: for rendering. */
|
||||
export function peekDetail(id: string, accountId?: string): Email | undefined {
|
||||
return details.get(keyOf(id, accountId))?.email;
|
||||
@@ -292,7 +303,7 @@ async function fetchBodies(
|
||||
for (const id of ids) {
|
||||
const email = found.get(id);
|
||||
if (email) waiting.get(id)?.resolve(email);
|
||||
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
|
||||
else waiting.get(id)?.reject(new EmailNotFoundError(id));
|
||||
}
|
||||
} catch (err) {
|
||||
for (const id of ids) waiting.get(id)?.reject(err);
|
||||
@@ -345,7 +356,7 @@ async function revalidate(
|
||||
for (const id of ids) {
|
||||
const email = peekDetail(id, accountId);
|
||||
if (email) waiting.get(id)?.resolve(email);
|
||||
else waiting.get(id)?.reject(new Error(`Email ${id} not found`));
|
||||
else waiting.get(id)?.reject(gone.has(id) ? new EmailNotFoundError(id) : new Error(`Email ${id} not found`));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
import { patchKeywordsForEmails } from '../api/email';
|
||||
import {
|
||||
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
|
||||
type FlagsHint,
|
||||
EmailNotFoundError, type FlagsHint,
|
||||
} from '../lib/email-detail-cache';
|
||||
import { shareEmailEml } from '../lib/email-export';
|
||||
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
||||
@@ -237,9 +237,13 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
|
||||
// Cache first: a held copy renders at once and the promise resolves with the
|
||||
// checked one (which mark-as-read goes by). Concurrent loads share a request.
|
||||
// `onGone` hears when the server has no such message any more.
|
||||
const ensureDetail = React.useCallback(
|
||||
(id: string): Promise<Email | null> =>
|
||||
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
|
||||
(id: string, onGone?: () => void): Promise<Email | null> =>
|
||||
loadDetail(id, ownerAccountId, hintFor(id)).catch((err) => {
|
||||
if (err instanceof EmailNotFoundError) onGone?.();
|
||||
return peekDetail(id, ownerAccountId) ?? null;
|
||||
}),
|
||||
[ownerAccountId, hintFor],
|
||||
);
|
||||
const ensureDetails = React.useCallback((ids: string[]) => {
|
||||
@@ -418,10 +422,18 @@ function EmailViewer({ route, navigation, embedded = false, onActiveChange }: Pr
|
||||
// this lands. Then mark it read per the user's delay.
|
||||
setError(null);
|
||||
void (async () => {
|
||||
const fetched = await ensureDetail(activeEmailId);
|
||||
let gone = false;
|
||||
const fetched = await ensureDetail(activeEmailId, () => { gone = true; });
|
||||
if (cancelled) return;
|
||||
if (!fetched) {
|
||||
if (!detailOf(activeEmailId)) setError(t('email_viewer.load_failed', 'Failed to load email'));
|
||||
if (!detailOf(activeEmailId)) {
|
||||
// Deleted or moved elsewhere: say so (webmail deep_link
|
||||
// message_not_found) and let the list drop its row.
|
||||
setError(gone
|
||||
? t('deep_link.message_not_found', 'This message is no longer available.')
|
||||
: t('email_viewer.load_failed', 'Failed to load email'));
|
||||
if (gone) void useEmailStore.getState().refreshEmails();
|
||||
}
|
||||
return;
|
||||
}
|
||||
cancelRead = scheduleMarkRead(fetched);
|
||||
|
||||
Reference in New Issue
Block a user