mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 16:07:32 +00:00
608 lines
22 KiB
TypeScript
608 lines
22 KiB
TypeScript
// The messages the viewer has read, kept across viewer instances so opening a
|
|
// message again (or swiping back to it) paints its body at once instead of
|
|
// waiting for the network.
|
|
//
|
|
// A body never changes once a message exists (RFC 8621 §4.1): only its
|
|
// keywords and mailboxes do. So a copy held here is shown straight away and
|
|
// revalidated in the background, and only when something may have changed:
|
|
// the account's Email state moved on since the copy was read, or the list row
|
|
// the viewer was opened from disagrees with it. Revalidating asks for just the
|
|
// keywords and mailboxes. Concurrent loads of the same message or thread share
|
|
// one request, which is what stops the pager's panes and a tap's prefetch from
|
|
// fetching the same body twice.
|
|
|
|
import { AppState } from 'react-native';
|
|
import type { Email } from '../api/types';
|
|
import { jmapClient } from '../api/jmap-client';
|
|
import { getEmailFlags, getFullEmailsWithState, getThreadsHeaders } from '../api/email';
|
|
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
|
import { useSettingsStore } from '../stores/settings-store';
|
|
import { onStateChangeType } from './state-change-bus';
|
|
import { withInlineBodyParts } from './email-inline-parts';
|
|
|
|
/** What the list row the viewer was opened from says about the message. */
|
|
export type FlagsHint = Partial<Pick<Email, 'keywords' | 'mailboxIds'>>;
|
|
|
|
interface DetailEntry {
|
|
email: Email;
|
|
/** The account's Email state this copy was read or last checked at. */
|
|
state?: string;
|
|
/** Rough size of the bodies, for the memory cap. */
|
|
chars: number;
|
|
}
|
|
|
|
interface ThreadEntry {
|
|
/** Member ids, oldest first. */
|
|
ids: string[];
|
|
/** Members without bodies (list properties). */
|
|
headers: Map<string, Email>;
|
|
state?: string;
|
|
}
|
|
|
|
export interface ThreadView {
|
|
ids: string[];
|
|
headers: ReadonlyMap<string, Email>;
|
|
}
|
|
|
|
// Large newsletters run to hundreds of KB, so the cap is by size as well as count.
|
|
const MAX_DETAILS = 40;
|
|
const MAX_DETAIL_CHARS = 8_000_000;
|
|
const MAX_THREADS = 60;
|
|
// List rows handed to the viewer by screens whose list it cannot read (the
|
|
// Unified Inbox, contact activity), so it can paint their headers too.
|
|
const MAX_ROWS = 500;
|
|
|
|
const details = new Map<string, DetailEntry>();
|
|
const threads = new Map<string, ThreadEntry>();
|
|
const rows = new Map<string, Email>();
|
|
const pendingDetails = new Map<string, Promise<Email>>();
|
|
const pendingThreads = new Map<string, Promise<ThreadView>>();
|
|
// The newest Email state heard of per account, from push and our own reads.
|
|
const latestStates = new Map<string, string>();
|
|
const listeners = new Set<() => void>();
|
|
let detailChars = 0;
|
|
let wired = false;
|
|
// Bumped by clearEmailDetailCache: a load started before must not refill it.
|
|
let generation = 0;
|
|
|
|
// JMAP ids are only unique per account (RFC 8621 §1.3), and two signed-in
|
|
// servers can hand out the same account ids, so the key names both.
|
|
function accountKey(accountId: string | undefined): string {
|
|
let own = '';
|
|
try { own = jmapClient.accountId; } catch { /* not connected */ }
|
|
return `${jmapClient.serverUrl ?? ''}|${jmapClient.username ?? ''}|${accountId ?? own}`;
|
|
}
|
|
|
|
function keyOf(id: string, accountId: string | undefined): string {
|
|
return `${accountKey(accountId)}|${id}`;
|
|
}
|
|
|
|
function bodyChars(email: Email): number {
|
|
let n = 2_000;
|
|
for (const v of Object.values(email.bodyValues ?? {})) n += v?.value?.length ?? 0;
|
|
return n;
|
|
}
|
|
|
|
function emit(): void {
|
|
for (const l of listeners) {
|
|
try { l(); } catch { /* a broken listener must not break the others */ }
|
|
}
|
|
}
|
|
|
|
// The Email state is only worth trusting while push is delivering changes:
|
|
// events that happen while the app is in the background are lost.
|
|
function wire(): void {
|
|
if (wired) return;
|
|
wired = true;
|
|
onStateChangeType('Email', (accountId, state) => {
|
|
latestStates.set(accountKey(accountId), state);
|
|
});
|
|
try {
|
|
AppState.addEventListener('change', (next) => {
|
|
if (next !== 'active') latestStates.clear();
|
|
});
|
|
} catch { /* no AppState (tests) */ }
|
|
}
|
|
|
|
/** Record the account's current Email state (push event or a response). */
|
|
export function noteEmailState(accountId: string | undefined, state: string | undefined): void {
|
|
if (state) latestStates.set(accountKey(accountId), state);
|
|
}
|
|
|
|
function sameFlags(a: Record<string, boolean> | undefined, b: Record<string, boolean> | undefined): boolean {
|
|
const ak = Object.keys(a ?? {}).filter((k) => a![k]);
|
|
const bk = Object.keys(b ?? {}).filter((k) => b![k]);
|
|
return ak.length === bk.length && ak.every((k) => !!b?.[k]);
|
|
}
|
|
|
|
/**
|
|
* Whether a copy read at `copyState` has to be checked against the server
|
|
* before it is trusted: the account's Email state is unknown or has moved on,
|
|
* or the list row the message was opened from disagrees with the copy.
|
|
*/
|
|
export function needsRevalidation(
|
|
copy: Pick<Email, 'keywords' | 'mailboxIds'>,
|
|
copyState: string | undefined,
|
|
latestState: string | undefined,
|
|
hint?: FlagsHint,
|
|
): boolean {
|
|
if (!copyState || !latestState || copyState !== latestState) return true;
|
|
if (hint?.keywords && !sameFlags(hint.keywords, copy.keywords)) return true;
|
|
if (hint?.mailboxIds && !sameFlags(hint.mailboxIds, copy.mailboxIds)) return true;
|
|
return false;
|
|
}
|
|
|
|
function putDetail(key: string, entry: DetailEntry): void {
|
|
const prev = details.get(key);
|
|
if (prev) {
|
|
detailChars -= prev.chars;
|
|
details.delete(key);
|
|
}
|
|
details.set(key, entry);
|
|
detailChars += entry.chars;
|
|
// Evict least recently used, never the entry just stored.
|
|
for (const [k, e] of details) {
|
|
if (details.size <= MAX_DETAILS && detailChars <= MAX_DETAIL_CHARS) break;
|
|
if (k === key) continue;
|
|
details.delete(k);
|
|
detailChars -= e.chars;
|
|
}
|
|
}
|
|
|
|
function touchDetail(key: string, entry: DetailEntry): void {
|
|
details.delete(key);
|
|
details.set(key, entry);
|
|
}
|
|
|
|
function putThread(key: string, entry: ThreadEntry): void {
|
|
threads.delete(key);
|
|
threads.set(key, entry);
|
|
while (threads.size > MAX_THREADS) {
|
|
const oldest = threads.keys().next().value as string;
|
|
threads.delete(oldest);
|
|
}
|
|
}
|
|
|
|
// Local changes (patchDetail) by message key, numbered in the order they
|
|
// were made. A response read before one of them must not undo it: the read
|
|
// mark set on open was put back by the conversation's headers arriving just
|
|
// after it, and the reader's toolbar offered "Mark as read" again.
|
|
let localClock = 0;
|
|
const localPatches = new Map<string, { at: number; patch: FlagsHint }>();
|
|
const MAX_LOCAL_PATCHES = 500;
|
|
|
|
/** What was changed here on `key` after a request started at `startedAt` went out. */
|
|
function localSince(key: string, startedAt: number): FlagsHint | undefined {
|
|
const local = localPatches.get(key);
|
|
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;
|
|
}
|
|
|
|
/** The conversation held for a thread, if any. */
|
|
export function peekThread(threadId: string, accountId?: string): ThreadView | undefined {
|
|
return threads.get(keyOf(threadId, accountId));
|
|
}
|
|
|
|
/** Re-render on every change to what is held here. */
|
|
export function subscribeEmailCache(listener: () => void): () => void {
|
|
listeners.add(listener);
|
|
return () => { listeners.delete(listener); };
|
|
}
|
|
|
|
/**
|
|
* Apply a local change (star, read, tag, `$answered`…) to the held copy and to
|
|
* any conversation that lists the message, so the next open shows it.
|
|
*/
|
|
export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void {
|
|
const key = keyOf(id, accountId);
|
|
// Re-inserted, so the oldest change is the first to go.
|
|
const earlier = localPatches.get(key)?.patch;
|
|
localPatches.delete(key);
|
|
localPatches.set(key, { at: ++localClock, patch: { ...earlier, ...patch } });
|
|
while (localPatches.size > MAX_LOCAL_PATCHES) {
|
|
localPatches.delete(localPatches.keys().next().value as string);
|
|
}
|
|
let changed = false;
|
|
const entry = details.get(key);
|
|
if (entry) {
|
|
details.set(key, { ...entry, email: { ...entry.email, ...patch } });
|
|
changed = true;
|
|
}
|
|
const prefix = accountKey(accountId);
|
|
for (const [k, thread] of threads) {
|
|
if (!k.startsWith(`${prefix}|`)) continue;
|
|
const header = thread.headers.get(id);
|
|
if (!header) continue;
|
|
const headers = new Map(thread.headers);
|
|
headers.set(id, { ...header, ...patch });
|
|
threads.set(k, { ...thread, headers });
|
|
changed = true;
|
|
}
|
|
if (changed) emit();
|
|
}
|
|
|
|
/**
|
|
* Drop everything held, and keep loads still in flight from storing what
|
|
* they bring (sign-out, account removal).
|
|
*/
|
|
export function clearEmailDetailCache(): void {
|
|
generation++;
|
|
details.clear();
|
|
threads.clear();
|
|
rows.clear();
|
|
pendingDetails.clear();
|
|
pendingThreads.clear();
|
|
threadBatches.clear();
|
|
latestStates.clear();
|
|
localPatches.clear();
|
|
detailChars = 0;
|
|
}
|
|
|
|
interface Deferred<T> {
|
|
promise: Promise<T>;
|
|
resolve: (value: T) => void;
|
|
reject: (err: unknown) => void;
|
|
}
|
|
|
|
function deferred<T>(): Deferred<T> {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (err: unknown) => void;
|
|
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej; });
|
|
// Callers may drop the promise (prefetch); an unobserved rejection is noise.
|
|
promise.catch(() => undefined);
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
// Keep the offline copy's keywords/folders current: the next offline open
|
|
// should not show a star or a folder from weeks ago.
|
|
function refreshOfflineCopy(email: Email, accountId: string | undefined): void {
|
|
const offline = useOfflineCacheStore.getState();
|
|
if (!offline.has(email.id, accountId)) return;
|
|
try {
|
|
void offline.put(email, JSON.stringify(email).length, accountId).catch(() => undefined);
|
|
} catch { /* best effort */ }
|
|
}
|
|
|
|
async function fetchBodies(
|
|
ids: string[],
|
|
accountId: string | undefined,
|
|
waiting: Map<string, Deferred<Email>>,
|
|
): Promise<void> {
|
|
if (ids.length === 0) return;
|
|
const gen = generation;
|
|
const startedAt = localClock;
|
|
try {
|
|
const res = await getFullEmailsWithState(ids, accountId);
|
|
const keep = gen === generation;
|
|
if (keep) noteEmailState(accountId, res.state);
|
|
const found = new Map<string, Email>();
|
|
for (const fetched of res.list) {
|
|
const local = localSince(keyOf(fetched.id, accountId), startedAt);
|
|
const email = local ? { ...fetched, ...local } : fetched;
|
|
found.set(email.id, email);
|
|
if (!keep) continue;
|
|
putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) });
|
|
refreshOfflineCopy(email, accountId);
|
|
}
|
|
if (keep) emit();
|
|
for (const id of ids) {
|
|
const email = found.get(id);
|
|
if (email) waiting.get(id)?.resolve(email);
|
|
else waiting.get(id)?.reject(new EmailNotFoundError(id));
|
|
}
|
|
} catch (err) {
|
|
for (const id of ids) waiting.get(id)?.reject(err);
|
|
}
|
|
}
|
|
|
|
async function revalidate(
|
|
ids: string[],
|
|
accountId: string | undefined,
|
|
waiting: Map<string, Deferred<Email>>,
|
|
): Promise<void> {
|
|
if (ids.length === 0) return;
|
|
const gen = generation;
|
|
const startedAt = localClock;
|
|
let gone = new Set<string>();
|
|
try {
|
|
const res = await getEmailFlags(ids, accountId);
|
|
if (gen !== generation) throw new Error('cleared');
|
|
noteEmailState(accountId, res.state);
|
|
gone = new Set(res.notFound);
|
|
let changed = false;
|
|
for (const read of res.list) {
|
|
const key = keyOf(read.id, accountId);
|
|
const entry = details.get(key);
|
|
if (!entry) continue;
|
|
const flags = { ...read, ...localSince(key, startedAt) };
|
|
const same = sameFlags(flags.keywords, entry.email.keywords)
|
|
&& sameFlags(flags.mailboxIds, entry.email.mailboxIds);
|
|
// Keep the object when nothing changed, so nothing re-renders.
|
|
const email = same ? entry.email : { ...entry.email, keywords: flags.keywords, mailboxIds: flags.mailboxIds };
|
|
details.set(key, { ...entry, email, state: res.state });
|
|
if (!same) {
|
|
changed = true;
|
|
refreshOfflineCopy(email, accountId);
|
|
}
|
|
}
|
|
// Destroyed since it was read: not something to show as current.
|
|
for (const id of gone) {
|
|
const key = keyOf(id, accountId);
|
|
const entry = details.get(key);
|
|
if (!entry) continue;
|
|
details.delete(key);
|
|
detailChars -= entry.chars;
|
|
changed = true;
|
|
}
|
|
if (changed) emit();
|
|
} catch {
|
|
// Offline or refused: the copy held is still the best there is.
|
|
}
|
|
for (const id of ids) {
|
|
const email = peekDetail(id, accountId);
|
|
if (email) waiting.get(id)?.resolve(email);
|
|
else waiting.get(id)?.reject(gone.has(id) ? new EmailNotFoundError(id) : new Error(`Email ${id} not found`));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start loading messages, cache first, and return a promise per id:
|
|
* - a held copy that is still current resolves at once;
|
|
* - one that may be stale is shown and checked with a single
|
|
* keywords/folders request for all of them;
|
|
* - a copy in the offline cache is shown, then checked the same way;
|
|
* - only the rest are downloaded, in one `Email/get`.
|
|
* A load already running for an id is joined instead of repeated.
|
|
*/
|
|
function startLoads(
|
|
ids: string[],
|
|
accountId: string | undefined,
|
|
hints?: Record<string, FlagsHint | undefined>,
|
|
): Map<string, Promise<Email>> {
|
|
wire();
|
|
const latest = latestStates.get(accountKey(accountId));
|
|
const offline = useOfflineCacheStore.getState();
|
|
const results = new Map<string, Promise<Email>>();
|
|
const waiting = new Map<string, Deferred<Email>>();
|
|
const stale: string[] = [];
|
|
const fromOffline: string[] = [];
|
|
const missing: string[] = [];
|
|
|
|
for (const id of new Set(ids)) {
|
|
const key = keyOf(id, accountId);
|
|
const pending = pendingDetails.get(key);
|
|
if (pending) {
|
|
results.set(id, pending);
|
|
continue;
|
|
}
|
|
const entry = details.get(key);
|
|
if (entry) {
|
|
touchDetail(key, entry);
|
|
if (!needsRevalidation(entry.email, entry.state, latest, hints?.[id])) {
|
|
results.set(id, Promise.resolve(entry.email));
|
|
continue;
|
|
}
|
|
}
|
|
const d = deferred<Email>();
|
|
waiting.set(id, d);
|
|
results.set(id, d.promise);
|
|
pendingDetails.set(key, d.promise);
|
|
const done = () => { if (pendingDetails.get(key) === d.promise) pendingDetails.delete(key); };
|
|
d.promise.then(done, done);
|
|
if (entry) stale.push(id);
|
|
else if (offline.has(id, accountId)) fromOffline.push(id);
|
|
else missing.push(id);
|
|
}
|
|
if (waiting.size === 0) return results;
|
|
|
|
void (async () => {
|
|
// An offline copy is shown right away, then checked like a held one.
|
|
if (fromOffline.length > 0) {
|
|
const gen = generation;
|
|
const copies = await Promise.all(fromOffline.map((id) => offline.get(id, accountId).catch(() => null)));
|
|
let shown = false;
|
|
fromOffline.forEach((id, i) => {
|
|
// A copy saved from Stalwart 1.0 before the inline parts were taken
|
|
// into `attachments` gets them here.
|
|
const copy = gen === generation ? copies[i] : null;
|
|
const email = copy ? withInlineBodyParts(copy) : null;
|
|
if (!email) {
|
|
missing.push(id);
|
|
return;
|
|
}
|
|
putDetail(keyOf(id, accountId), { email, chars: bodyChars(email) });
|
|
stale.push(id);
|
|
shown = true;
|
|
});
|
|
if (shown) emit();
|
|
}
|
|
await Promise.all([
|
|
fetchBodies(missing, accountId, waiting),
|
|
revalidate(stale, accountId, waiting),
|
|
]);
|
|
})();
|
|
return results;
|
|
}
|
|
|
|
/** Load several messages (see {@link startLoads}); ones that failed are left out. */
|
|
export async function loadDetails(
|
|
ids: string[],
|
|
accountId?: string,
|
|
hints?: Record<string, FlagsHint | undefined>,
|
|
): Promise<Map<string, Email>> {
|
|
const out = new Map<string, Email>();
|
|
await Promise.all(Array.from(startLoads(ids, accountId, hints), async ([id, p]) => {
|
|
try { out.set(id, await p); } catch { /* left out */ }
|
|
}));
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Load one message (see {@link startLoads}). Resolves with the checked copy,
|
|
* which is what decisions like mark-as-read must be based on; rejects when
|
|
* the message cannot be loaded.
|
|
*/
|
|
export function loadDetail(id: string, accountId?: string, hint?: FlagsHint): Promise<Email> {
|
|
return startLoads([id], accountId, hint ? { [id]: hint } : undefined).get(id)!;
|
|
}
|
|
|
|
/**
|
|
* A conversation's members without bodies, cache first. A held conversation
|
|
* resolves at once while the account's Email state says it is current; it is
|
|
* otherwise refetched and, if that fails, still returned. Conversations asked
|
|
* for in the same tick (the pages on both sides of the open one) share one
|
|
* `Thread/get` request.
|
|
*/
|
|
export function loadThread(threadId: string, accountId?: string): Promise<ThreadView> {
|
|
wire();
|
|
const key = keyOf(threadId, accountId);
|
|
const held = threads.get(key);
|
|
const latest = latestStates.get(accountKey(accountId));
|
|
if (held && held.state && held.state === latest) return Promise.resolve(held);
|
|
const pending = pendingThreads.get(key);
|
|
if (pending) return pending;
|
|
|
|
const d = deferred<ThreadView>();
|
|
pendingThreads.set(key, d.promise);
|
|
const done = () => { if (pendingThreads.get(key) === d.promise) pendingThreads.delete(key); };
|
|
d.promise.then(done, done);
|
|
|
|
const batchKey = accountKey(accountId);
|
|
let batch = threadBatches.get(batchKey);
|
|
if (!batch) {
|
|
batch = { accountId, waiting: new Map() };
|
|
threadBatches.set(batchKey, batch);
|
|
const queued = batch;
|
|
void Promise.resolve().then(() => {
|
|
if (threadBatches.get(batchKey) === queued) threadBatches.delete(batchKey);
|
|
void fetchThreads(queued);
|
|
});
|
|
}
|
|
batch.waiting.set(threadId, d);
|
|
return d.promise;
|
|
}
|
|
|
|
/**
|
|
* Fetch a conversation again even though the account's Email state says the
|
|
* held copy is current: something changed it here that the state doesn't
|
|
* know of yet, such as a reply draft just saved into it. The held copy keeps
|
|
* rendering until the new one lands. Undefined when the fetch fails.
|
|
*/
|
|
export function refreshThread(threadId: string, accountId?: string): Promise<ThreadView | undefined> {
|
|
const held = threads.get(keyOf(threadId, accountId));
|
|
if (held) held.state = undefined;
|
|
return loadThread(threadId, accountId).catch(() => undefined);
|
|
}
|
|
|
|
interface ThreadBatch {
|
|
accountId: string | undefined;
|
|
waiting: Map<string, Deferred<ThreadView>>;
|
|
}
|
|
|
|
// Conversations asked for in the current tick, per account, not yet requested.
|
|
const threadBatches = new Map<string, ThreadBatch>();
|
|
|
|
async function fetchThreads({ accountId, waiting }: ThreadBatch): Promise<void> {
|
|
const gen = generation;
|
|
// What was held when the load started: shown again if the refetch fails.
|
|
const fallback = (threadId: string) => (gen === generation ? threads.get(keyOf(threadId, accountId)) : undefined);
|
|
const startedAt = localClock;
|
|
try {
|
|
const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId);
|
|
if (gen !== generation) throw new Error('cleared');
|
|
noteEmailState(accountId, res.state);
|
|
for (const [threadId, d] of waiting) {
|
|
const found = res.threads[threadId];
|
|
if (!found) {
|
|
const held = fallback(threadId);
|
|
if (held) d.resolve(held);
|
|
else d.reject(new Error(`Thread ${threadId} not found`));
|
|
continue;
|
|
}
|
|
// A change made here while the headers were on their way stays.
|
|
const list = found.list.map((e) => {
|
|
const local = localSince(keyOf(e.id, accountId), startedAt);
|
|
return local ? { ...e, ...local } : e;
|
|
});
|
|
const entry: ThreadEntry = {
|
|
ids: found.emailIds,
|
|
headers: new Map(list.map((e) => [e.id, e])),
|
|
state: res.state,
|
|
};
|
|
putThread(keyOf(threadId, accountId), entry);
|
|
// The headers were just read: a member's copy held from an earlier
|
|
// open takes their keywords and folders, which may have changed since.
|
|
for (const header of list) {
|
|
const memberKey = keyOf(header.id, accountId);
|
|
const held = details.get(memberKey);
|
|
if (!held) continue;
|
|
if (sameFlags(header.keywords, held.email.keywords) && sameFlags(header.mailboxIds, held.email.mailboxIds)) {
|
|
continue;
|
|
}
|
|
details.set(memberKey, {
|
|
...held,
|
|
email: { ...held.email, keywords: header.keywords, mailboxIds: header.mailboxIds },
|
|
state: res.state,
|
|
});
|
|
}
|
|
d.resolve(entry);
|
|
}
|
|
emit();
|
|
} catch (err) {
|
|
for (const [threadId, d] of waiting) {
|
|
const held = fallback(threadId);
|
|
if (held) d.resolve(held);
|
|
else d.reject(err);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Keep list rows the viewer may page over; see {@link peekRow}. */
|
|
export function rememberRows(list: Email[], accountId?: string): void {
|
|
for (const row of list) {
|
|
if (!row.receivedAt) continue;
|
|
const key = keyOf(row.id, accountId);
|
|
rows.delete(key);
|
|
rows.set(key, row);
|
|
}
|
|
while (rows.size > MAX_ROWS) rows.delete(rows.keys().next().value as string);
|
|
}
|
|
|
|
/** A list row remembered for the message, if any. */
|
|
export function peekRow(id: string, accountId?: string): Email | undefined {
|
|
return rows.get(keyOf(id, accountId));
|
|
}
|
|
|
|
/**
|
|
* Start loading a message the user is about to open (the list row was
|
|
* tapped), and its conversation when threading is on, so the viewer finds the
|
|
* requests in flight or done. On a phone this runs on the tap, not on
|
|
* press-in: a touch that starts a scroll presses rows too.
|
|
*/
|
|
export function prefetchMessage(
|
|
email: Pick<Email, 'id' | 'threadId'> & Partial<Email>,
|
|
accountId?: string,
|
|
): void {
|
|
rememberRows([email as Email], accountId);
|
|
const hint = email.keywords ? { keywords: email.keywords, mailboxIds: email.mailboxIds } : undefined;
|
|
void loadDetail(email.id, accountId, hint).catch(() => undefined);
|
|
if (email.threadId && !useSettingsStore.getState().disableThreading) {
|
|
void loadThread(email.threadId, accountId).catch(() => undefined);
|
|
}
|
|
}
|