mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 00:06:23 +00:00
fix: keep the reader's read state when the conversation loads after marking it read
This commit is contained in:
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user