fix: keep the reader's read state when the conversation loads after marking it read

This commit is contained in:
Linus Rath
2026-10-31 13:52:38 +01:00
parent 461e4da0b2
commit 9bc7b4a5b8
2 changed files with 81 additions and 5 deletions
@@ -320,6 +320,49 @@ describe('loading a conversation', () => {
expect(threadGet).toHaveBeenCalledWith(['t1'], 'group'); expect(threadGet).toHaveBeenCalledWith(['t1'], 'group');
}); });
it('keeps a read mark made while the conversation was loading', async () => {
fullGet.mockResolvedValueOnce(fullResponse([full('m1')], 's1'));
await loadDetail('m1');
let answer!: (v: unknown) => void;
threadGet.mockReturnValueOnce(new Promise((r) => { answer = r; }));
const loading = loadThread('t-m1');
await flush();
// Marked read on open, while Thread/get is out.
patchDetail('m1', undefined, { keywords: { $seen: true } });
answer(threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: {}, mailboxIds: { inbox: true } }], 's2'));
await loading;
expect(peekDetail('m1')?.keywords).toEqual({ $seen: true });
expect(peekThread('t-m1')?.headers.get('m1')?.keywords).toEqual({ $seen: true });
});
it('keeps a read mark made while the message itself was loading', async () => {
let answer!: (v: unknown) => void;
fullGet.mockReturnValueOnce(new Promise((r) => { answer = r; }));
threadGet.mockResolvedValueOnce(threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: {}, mailboxIds: { inbox: true } }]));
await loadThread('t-m1');
const loading = loadDetail('m1');
await flush();
patchDetail('m1', undefined, { keywords: { $seen: true } });
answer(fullResponse([full('m1')], 's2'));
expect((await loading).keywords).toEqual({ $seen: true });
expect(peekDetail('m1')?.keywords).toEqual({ $seen: true });
});
it('takes the server keywords of a conversation read after the local change', async () => {
fullGet.mockResolvedValueOnce(fullResponse([full('m1')], 's1'));
await loadDetail('m1');
patchDetail('m1', undefined, { keywords: { $seen: true } });
threadGet.mockResolvedValueOnce(
threadResponse('t-m1', [{ id: 'm1', threadId: 't-m1', keywords: { $seen: true, $flagged: true }, mailboxIds: { inbox: true } }], 's2'),
);
await loadThread('t-m1');
expect(peekDetail('m1')?.keywords).toEqual({ $seen: true, $flagged: true });
});
it('applies local keyword changes to the conversation headers too', async () => { it('applies local keyword changes to the conversation headers too', async () => {
threadGet.mockResolvedValue(threadResponse('t1', headers(['m1']))); threadGet.mockResolvedValue(threadResponse('t1', headers(['m1'])));
await loadThread('t1'); await loadThread('t1');
+38 -5
View File
@@ -163,6 +163,20 @@ function putThread(key: string, entry: ThreadEntry): void {
} }
} }
// 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 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;
@@ -185,6 +199,13 @@ export function subscribeEmailCache(listener: () => void): () => void {
*/ */
export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void { export function patchDetail(id: string, accountId: string | undefined, patch: FlagsHint): void {
const key = keyOf(id, accountId); 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; let changed = false;
const entry = details.get(key); const entry = details.get(key);
if (entry) { if (entry) {
@@ -217,6 +238,7 @@ export function clearEmailDetailCache(): void {
pendingThreads.clear(); pendingThreads.clear();
threadBatches.clear(); threadBatches.clear();
latestStates.clear(); latestStates.clear();
localPatches.clear();
detailChars = 0; detailChars = 0;
} }
@@ -252,12 +274,15 @@ async function fetchBodies(
): Promise<void> { ): Promise<void> {
if (ids.length === 0) return; if (ids.length === 0) return;
const gen = generation; const gen = generation;
const startedAt = localClock;
try { try {
const res = await getFullEmailsWithState(ids, accountId); const res = await getFullEmailsWithState(ids, accountId);
const keep = gen === generation; const keep = gen === generation;
if (keep) noteEmailState(accountId, res.state); if (keep) noteEmailState(accountId, res.state);
const found = new Map<string, Email>(); const found = new Map<string, Email>();
for (const email of res.list) { 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); found.set(email.id, email);
if (!keep) continue; if (!keep) continue;
putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) }); putDetail(keyOf(email.id, accountId), { email, state: res.state, chars: bodyChars(email) });
@@ -281,6 +306,7 @@ async function revalidate(
): Promise<void> { ): Promise<void> {
if (ids.length === 0) return; if (ids.length === 0) return;
const gen = generation; const gen = generation;
const startedAt = localClock;
let gone = new Set<string>(); let gone = new Set<string>();
try { try {
const res = await getEmailFlags(ids, accountId); const res = await getEmailFlags(ids, accountId);
@@ -288,10 +314,11 @@ async function revalidate(
noteEmailState(accountId, res.state); noteEmailState(accountId, res.state);
gone = new Set(res.notFound); gone = new Set(res.notFound);
let changed = false; let changed = false;
for (const flags of res.list) { for (const read of res.list) {
const key = keyOf(flags.id, accountId); const key = keyOf(read.id, accountId);
const entry = details.get(key); const entry = details.get(key);
if (!entry) continue; if (!entry) continue;
const flags = { ...read, ...localSince(key, startedAt) };
const same = sameFlags(flags.keywords, entry.email.keywords) const same = sameFlags(flags.keywords, entry.email.keywords)
&& sameFlags(flags.mailboxIds, entry.email.mailboxIds); && sameFlags(flags.mailboxIds, entry.email.mailboxIds);
// Keep the object when nothing changed, so nothing re-renders. // Keep the object when nothing changed, so nothing re-renders.
@@ -471,6 +498,7 @@ async function fetchThreads({ accountId, waiting }: ThreadBatch): Promise<void>
const gen = generation; const gen = generation;
// What was held when the load started: shown again if the refetch fails. // 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 fallback = (threadId: string) => (gen === generation ? threads.get(keyOf(threadId, accountId)) : undefined);
const startedAt = localClock;
try { try {
const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId); const res = await getThreadsHeaders(Array.from(waiting.keys()), accountId);
if (gen !== generation) throw new Error('cleared'); if (gen !== generation) throw new Error('cleared');
@@ -483,15 +511,20 @@ async function fetchThreads({ accountId, waiting }: ThreadBatch): Promise<void>
else d.reject(new Error(`Thread ${threadId} not found`)); else d.reject(new Error(`Thread ${threadId} not found`));
continue; 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 = { const entry: ThreadEntry = {
ids: found.emailIds, ids: found.emailIds,
headers: new Map(found.list.map((e) => [e.id, e])), headers: new Map(list.map((e) => [e.id, e])),
state: res.state, state: res.state,
}; };
putThread(keyOf(threadId, accountId), entry); putThread(keyOf(threadId, accountId), entry);
// The headers were just read: a member's copy held from an earlier // The headers were just read: a member's copy held from an earlier
// open takes their keywords and folders, which may have changed since. // open takes their keywords and folders, which may have changed since.
for (const header of found.list) { for (const header of list) {
const memberKey = keyOf(header.id, accountId); const memberKey = keyOf(header.id, accountId);
const held = details.get(memberKey); const held = details.get(memberKey);
if (!held) continue; if (!held) continue;