fix: keep sidebar tag counts in step with read/unread changes

Marking mail as read left the sidebar's tag unread counts untouched — the
folder counts cleared, but a tag went on showing "47 unread" in bold until
the page was reloaded.

tagCounts is fetched from the server (Email/query per $label keyword) rather
than derived from state, and no read/unread mutation refreshed or adjusted
it. The per-mailbox unreadEmails counters were kept current by a local delta;
tags simply had no equivalent.

Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and
apply it wherever the affected emails are known locally: markAsRead,
batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a
count, so re-marking a read email as read cannot drift it, and unread is
clamped at zero. A tag's total is never touched by a read-state change.

markMailboxAsRead is the exception and refetches instead: it is a server-side
bulk operation over an entire mailbox, so it also marks emails that were never
loaded into state.emails, and a local delta would leave the counts high.
This commit is contained in:
honzup
2026-07-13 21:19:08 +02:00
committed by Linus Rath
parent b1f6758f98
commit 9072bf8470
2 changed files with 329 additions and 8 deletions
+72 -8
View File
@@ -584,6 +584,37 @@ function applyBatchMailboxCounterUpdate(
return { mailboxes, accountMailboxes };
}
// Sidebar tag badges render from `tagCounts`, which is *fetched from the server*
// (`fetchTagCounts` -> `getTagCounts`) rather than derived from `state.emails`.
// So a read/unread mutation has to keep it in step locally, exactly as it does
// for `mailboxes[].unreadEmails` - otherwise the tag unread count (and the bold
// tag name) stays stale until a full page reload.
//
// `changes` carries one entry per email whose read state *actually changed*
// (callers already compute that), with delta -1 when it became read and +1 when
// it became unread. Only `unread` moves: read state never changes tag
// membership, so `total` is left alone.
function applyTagCountReadDelta(
tagCounts: Record<string, { total: number; unread: number }>,
changes: Array<{ keywords?: Record<string, boolean>; delta: number }>,
): Record<string, { total: number; unread: number }> {
const keywordIds = useSettingsStore.getState().emailKeywords.map(k => k.id);
if (keywordIds.length === 0) return tagCounts;
let next: Record<string, { total: number; unread: number }> | null = null;
for (const { keywords, delta } of changes) {
if (!keywords || delta === 0) continue;
for (const id of keywordIds) {
if (!keywords[`$label:${id}`]) continue;
const current = (next ?? tagCounts)[id];
if (!current) continue; // Tag not in the fetched counts yet; nothing to adjust.
next = next ?? { ...tagCounts };
next[id] = { total: current.total, unread: Math.max(0, current.unread + delta) };
}
}
return next ?? tagCounts;
}
// Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a
// group of emails from a folder: decrement total (and unread for unseen) for
// each group email that lives in the mailbox.
@@ -1421,6 +1452,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
...mailboxPatch,
// Same delta, applied to every tag this email carries, so the sidebar
// tag badges track the folder counters instead of going stale.
tagCounts: applyTagCountReadDelta(state.tagCounts, [
{ keywords: emailInState.keywords, delta },
]),
processingReadStatus: newProcessingSet,
// Also update threadEmailsCache so expanded dropdowns reflect the change
threadEmailsCache: (() => {
@@ -1976,14 +2012,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
setEmailKeywordsLocal: (emailId, keywords) => {
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
}));
set((state) => {
// This patch replaces the whole keyword map, so it can flip $seen as well
// as labels. Only a genuine read-state change moves the tag unread counts.
const previous = state.emails.find(e => e.id === emailId) ?? state.selectedEmail;
const wasRead = previous?.keywords?.$seen ?? false;
const isRead = keywords.$seen ?? false;
const delta = wasRead === isRead ? 0 : (isRead ? -1 : 1);
return {
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
tagCounts: applyTagCountReadDelta(state.tagCounts, [{ keywords, delta }]),
};
});
},
// Batch operations
@@ -2041,9 +2087,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
};
});
// Tag badges follow the same delta as the folder counters, counting only
// the emails whose read state actually changed.
const tagCounts = applyTagCountReadDelta(
get().tagCounts,
affectedEmails
.filter(email => (email.keywords?.$seen ?? false) !== read)
.map(email => ({ keywords: email.keywords, delta: read ? -1 : 1 })),
);
set({
emails: updatedEmails,
...mailboxPatch,
tagCounts,
selectedEmailIds: new Set(),
isLoading: false
});
@@ -3158,6 +3214,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
),
}));
// Tag counts are refetched here rather than adjusted with a local delta
// (as markAsRead/batchMarkAsRead do). This is a server-side bulk operation
// over the *whole* mailbox, so it also marks emails that were never loaded
// into `state.emails` - a local delta would only see the loaded page and
// would leave the tag counts drifting high. Fire-and-forget: the folder
// counters above already update instantly.
void get().fetchTagCounts(client);
return count;
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' });