mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +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 { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
vi.mock('../../api/jmap-client', () => ({
|
vi.mock('../../api/jmap-client', () => ({
|
||||||
jmapClient: { accountId: 'own', serverUrl: 'https://mail.example', username: 'me' },
|
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 { useOfflineCacheStore } from '../../stores/offline-cache-store';
|
||||||
import { dispatchStateChange } from '../state-change-bus';
|
import { dispatchStateChange } from '../state-change-bus';
|
||||||
import {
|
import {
|
||||||
clearEmailDetailCache, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
clearEmailDetailCache, EmailNotFoundError, loadDetail, loadDetails, loadThread, needsRevalidation, noteEmailState,
|
||||||
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
|
patchDetail, peekDetail, peekRow, peekThread, prefetchMessage, rememberRows, subscribeEmailCache,
|
||||||
} from '../email-detail-cache';
|
} from '../email-detail-cache';
|
||||||
|
|
||||||
@@ -158,6 +160,31 @@ describe('loading a message', () => {
|
|||||||
expect(peekDetail('e1')).toBeUndefined();
|
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 () => {
|
it('rejects with the fetch error when nothing is held', async () => {
|
||||||
fullGet.mockRejectedValue(new Error('boom'));
|
fullGet.mockRejectedValue(new Error('boom'));
|
||||||
await expect(loadDetail('e1')).rejects.toThrow('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;
|
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. */
|
/** The copy held for a message, if any. Synchronous: for rendering. */
|
||||||
export function peekDetail(id: string, accountId?: string): Email | undefined {
|
export function peekDetail(id: string, accountId?: string): Email | undefined {
|
||||||
return details.get(keyOf(id, accountId))?.email;
|
return details.get(keyOf(id, accountId))?.email;
|
||||||
@@ -292,7 +303,7 @@ async function fetchBodies(
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const email = found.get(id);
|
const email = found.get(id);
|
||||||
if (email) waiting.get(id)?.resolve(email);
|
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) {
|
} catch (err) {
|
||||||
for (const id of ids) waiting.get(id)?.reject(err);
|
for (const id of ids) waiting.get(id)?.reject(err);
|
||||||
@@ -345,7 +356,7 @@ async function revalidate(
|
|||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
const email = peekDetail(id, accountId);
|
const email = peekDetail(id, accountId);
|
||||||
if (email) waiting.get(id)?.resolve(email);
|
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 { patchKeywordsForEmails } from '../api/email';
|
||||||
import {
|
import {
|
||||||
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
|
loadDetail, loadDetails, loadThread, patchDetail, peekDetail, peekRow, peekThread, subscribeEmailCache,
|
||||||
type FlagsHint,
|
EmailNotFoundError, type FlagsHint,
|
||||||
} from '../lib/email-detail-cache';
|
} from '../lib/email-detail-cache';
|
||||||
import { shareEmailEml } from '../lib/email-export';
|
import { shareEmailEml } from '../lib/email-export';
|
||||||
import { useKeywordsStore, useAccountKeywordDefs, keywordToken, type KeywordDef } from '../stores/keywords-store';
|
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
|
// 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.
|
// 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(
|
const ensureDetail = React.useCallback(
|
||||||
(id: string): Promise<Email | null> =>
|
(id: string, onGone?: () => void): Promise<Email | null> =>
|
||||||
loadDetail(id, ownerAccountId, hintFor(id)).catch(() => peekDetail(id, ownerAccountId) ?? null),
|
loadDetail(id, ownerAccountId, hintFor(id)).catch((err) => {
|
||||||
|
if (err instanceof EmailNotFoundError) onGone?.();
|
||||||
|
return peekDetail(id, ownerAccountId) ?? null;
|
||||||
|
}),
|
||||||
[ownerAccountId, hintFor],
|
[ownerAccountId, hintFor],
|
||||||
);
|
);
|
||||||
const ensureDetails = React.useCallback((ids: string[]) => {
|
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.
|
// this lands. Then mark it read per the user's delay.
|
||||||
setError(null);
|
setError(null);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const fetched = await ensureDetail(activeEmailId);
|
let gone = false;
|
||||||
|
const fetched = await ensureDetail(activeEmailId, () => { gone = true; });
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (!fetched) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
cancelRead = scheduleMarkRead(fetched);
|
cancelRead = scheduleMarkRead(fetched);
|
||||||
|
|||||||
Reference in New Issue
Block a user