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:
+72
-8
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user