diff --git a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx index 53d7b6c1e0..4726813220 100644 --- a/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx +++ b/apps/web/app/(app)/[emailAccountId]/mail/MailShell.tsx @@ -55,6 +55,7 @@ import { useThreadPrefetchCoordinator } from "@/app/(app)/[emailAccountId]/mail/ import { useThreadActions } from "@/app/(app)/[emailAccountId]/mail/use-thread-actions"; import { useThreadSelection } from "@/app/(app)/[emailAccountId]/mail/use-thread-selection"; import { isThreadUnread } from "@/app/(app)/[emailAccountId]/mail/read-state"; +import { getInboxUnreadDelta } from "@/app/(app)/[emailAccountId]/mail/inbox-unread-count"; import { MailLayout, MailSplitKind } from "@/generated/prisma/enums"; import { useChat } from "@/providers/ChatProvider"; import { Sidebar, useSidebar } from "@/components/ui/sidebar"; @@ -136,7 +137,11 @@ export function MailShell() { const { userLabels } = useEmail(); const { visibleLabels, mutate: mutateLabels } = useSplitLabels(); const { folders, mutate: mutateFolders } = useFolders(provider); - const { countsById, mutate: mutateCounts } = useLabelCounts(); + const { + adjustInboxUnread, + countsById, + mutate: mutateCounts, + } = useLabelCounts({ emailAccountId }); const { data: settings, mutate: mutateSettings } = useMailSettings(); const { onOpen: openCompose } = useComposeModal(); const { setInput: setChatInput } = useChat(); @@ -420,12 +425,54 @@ export function MailShell() { openThreadSelection, readerSelectionSettled, ]); - const { archive, trash, markRead, markSpam, setReadState, snooze, undo } = - useThreadActions({ - emailAccountId, - readerTarget, - threads, - }); + const { + archive, + trash, + markSpam, + setReadState: queueReadState, + snooze, + undo, + } = useThreadActions({ + emailAccountId, + readerTarget, + threads, + }); + const inboxFolderId = folders.find( + (folder) => folder.systemType === "INBOX", + )?.id; + // Behind a ref so setReadState stays referentially stable across thread-list + // refreshes, matching useThreadActions. + const threadsRef = useRef(threads); + threadsRef.current = threads; + const setReadState = useCallback( + async (threadKeys: string[], read: boolean, notifySuccess = true) => { + const threadsBeforeQueue = threadsRef.current; + const queuedKeys = await queueReadState(threadKeys, read, notifySuccess); + if (!isAllAccounts) { + adjustInboxUnread( + getInboxUnreadDelta({ + countByMessage: isOutlook, + inboxFolderId, + read, + threadKeys: queuedKeys, + threads: threadsBeforeQueue, + }), + ); + } + return queuedKeys; + }, + [ + adjustInboxUnread, + inboxFolderId, + isAllAccounts, + isOutlook, + queueReadState, + ], + ); + const markRead = useCallback( + (threadKeys: string[]) => setReadState(threadKeys, true, false), + [setReadState], + ); const requestReaderReply = useCallback(() => { const messageId = openMessages.at(-1)?.id; if (messageId) { diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts new file mode 100644 index 0000000000..bf0ebe05d4 --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import type { ListThread } from "./types"; +import { getInboxUnreadDelta } from "./inbox-unread-count"; + +describe("getInboxUnreadDelta", () => { + it("decrements only unread inbox conversations marked as read", () => { + const threads = [ + createThread("unread-inbox", ["INBOX", "UNREAD"]), + createThread("read-inbox", ["INBOX"]), + createThread("unread-archive", ["UNREAD"]), + ]; + + expect( + getInboxUnreadDelta({ + read: true, + threadKeys: threads.map((thread) => thread.id), + threads, + }), + ).toBe(-1); + }); + + it("increments only read inbox conversations marked as unread", () => { + const threads = [ + createThread("read-inbox", ["INBOX"]), + createThread("unread-inbox", ["INBOX", "UNREAD"]), + ]; + + expect( + getInboxUnreadDelta({ + read: false, + threadKeys: threads.map((thread) => thread.id), + threads, + }), + ).toBe(1); + }); + + it("counts each unread Outlook inbox message", () => { + const thread = createThread("outlook", ["UNREAD"], "outlook-inbox", 2); + + expect( + getInboxUnreadDelta({ + countByMessage: true, + inboxFolderId: "outlook-inbox", + read: true, + threadKeys: [thread.id], + threads: [thread], + }), + ).toBe(-2); + }); +}); + +function createThread( + id: string, + labelIds: string[], + parentFolderId?: string, + messageCount = 1, +): ListThread { + return { + id, + snippet: "snippet", + plan: undefined, + plans: [], + messages: Array.from({ length: messageCount }, (_, index) => ({ + id: `message-${id}-${index}`, + threadId: id, + snippet: "snippet", + subject: "Subject", + date: "0", + internalDate: "0", + labelIds, + parentFolderId, + headers: { subject: "Subject" }, + })), + }; +} diff --git a/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts new file mode 100644 index 0000000000..37bb2bec6e --- /dev/null +++ b/apps/web/app/(app)/[emailAccountId]/mail/inbox-unread-count.ts @@ -0,0 +1,46 @@ +import { GmailLabel } from "@/utils/gmail/label"; +import { isThreadUnread } from "./read-state"; +import { getListThreadKey, type ListThread } from "./types"; + +export function getInboxUnreadDelta({ + countByMessage, + inboxFolderId, + read, + threadKeys, + threads, +}: { + countByMessage?: boolean; + inboxFolderId?: string; + read: boolean; + threadKeys: string[]; + threads: ListThread[]; +}) { + const targets = new Set(threadKeys); + let delta = 0; + + for (const thread of threads) { + if (!targets.has(getListThreadKey(thread))) continue; + if (countByMessage) { + const affectedMessages = thread.messages.filter((message) => { + const isInInbox = + message.labelIds?.includes(GmailLabel.INBOX) || + (inboxFolderId && message.parentFolderId === inboxFolderId); + const isUnread = message.labelIds?.includes(GmailLabel.UNREAD) ?? false; + return isInInbox && isUnread !== !read; + }); + delta += affectedMessages.length * (read ? -1 : 1); + continue; + } + + const isInInbox = thread.messages.some((message) => + message.labelIds?.includes(GmailLabel.INBOX), + ); + if (!isInInbox) continue; + + const isUnread = isThreadUnread(thread.messages); + if (isUnread === !read) continue; + delta += read ? -1 : 1; + } + + return delta; +} diff --git a/apps/web/app/api/labels/counts/route.test.ts b/apps/web/app/api/labels/counts/route.test.ts index 54adeeff36..1118544404 100644 --- a/apps/web/app/api/labels/counts/route.test.ts +++ b/apps/web/app/api/labels/counts/route.test.ts @@ -113,7 +113,7 @@ describe("GET /api/labels/counts", () => { expect(body.partial).toBe(false); }); - it("caches the response and serves later requests from the cache", async () => { + it("caches label counts but refreshes the inbox count on later requests", async () => { const first = await GET(request()); const firstBody = await first.json(); @@ -126,12 +126,26 @@ describe("GET /api/labels/counts", () => { mockGetLabelById.mockClear(); mockGetLabels.mockClear(); mockRedisGet.mockResolvedValue(firstBody); + mockGetLabelById.mockResolvedValue({ + id: "INBOX", + name: "Inbox", + threadsTotal: 8, + threadsUnread: 1, + }); const second = await GET(request()); + const secondBody = await second.json(); - expect(await second.json()).toEqual(firstBody); + expect(secondBody.counts[0]).toEqual({ + id: "INBOX", + name: "Inbox", + kind: "system", + total: 8, + unread: 1, + }); expect(mockGetLabels).not.toHaveBeenCalled(); - expect(mockGetLabelById).not.toHaveBeenCalled(); + expect(mockGetLabelById).toHaveBeenCalledOnce(); + expect(mockGetLabelById).toHaveBeenCalledWith("INBOX"); }); it("keeps the other counts when a single label lookup fails", async () => { diff --git a/apps/web/app/api/labels/counts/route.ts b/apps/web/app/api/labels/counts/route.ts index 827848b043..8696ac4bfb 100644 --- a/apps/web/app/api/labels/counts/route.ts +++ b/apps/web/app/api/labels/counts/route.ts @@ -64,7 +64,11 @@ export const GET = withEmailProvider( const { emailAccountId } = request.auth; const cached = await getCachedCounts(emailAccountId, logger); - if (cached) return NextResponse.json(cached); + if (cached) { + return NextResponse.json( + await refreshCachedInboxCount({ cached, emailProvider, logger }), + ); + } const { anyFailed, ...response } = await getCounts({ emailProvider, @@ -178,6 +182,46 @@ async function getGmailCounts({ }; } +async function refreshCachedInboxCount({ + cached, + emailProvider, + logger, +}: { + cached: LabelCountsResponse; + emailProvider: EmailProvider; + logger: Logger; +}) { + if (!cached.counts.some((count) => count.id === GmailLabel.INBOX)) { + return cached; + } + + try { + let fresh: { total: number; unread: number }; + if (isGoogleProvider(emailProvider.name)) { + // Not `getInboxStats`: for Gmail that reports message counts, while the + // sidebar shows thread counts. + const label = await emailProvider.getLabelById(GmailLabel.INBOX); + if (!label) return cached; + fresh = { + total: label.threadsTotal ?? 0, + unread: label.threadsUnread ?? 0, + }; + } else { + fresh = await emailProvider.getInboxStats(); + } + + return { + ...cached, + counts: cached.counts.map((count) => + count.id === GmailLabel.INBOX ? { ...count, ...fresh } : count, + ), + }; + } catch (error) { + logger.warn("Failed to refresh cached inbox count", { error }); + return cached; + } +} + function getCacheKey(emailAccountId: string) { return `${CACHE_KEY_PREFIX}:${emailAccountId}`; } diff --git a/apps/web/hooks/useLabelCounts.test.tsx b/apps/web/hooks/useLabelCounts.test.tsx new file mode 100644 index 0000000000..e6088315ea --- /dev/null +++ b/apps/web/hooks/useLabelCounts.test.tsx @@ -0,0 +1,195 @@ +// @vitest-environment jsdom + +import type { PropsWithChildren } from "react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { SWRConfig } from "swr"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useLabelCounts } from "./useLabelCounts"; + +const mailbox = vi.hoisted(() => { + const listeners = new Set<(emailAccountId: string) => void>(); + return { + emit(emailAccountId: string) { + for (const listener of listeners) listener(emailAccountId); + }, + reset() { + listeners.clear(); + }, + subscribe: vi.fn((listener: (emailAccountId: string) => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + }; +}); + +vi.mock("@/utils/email-cache/mailbox", () => ({ + subscribeToMailboxStore: mailbox.subscribe, +})); + +const initialResponse = { + counts: [ + { + id: "INBOX", + name: "Inbox", + kind: "system" as const, + total: 10, + unread: 4, + }, + ], + partial: false, +}; + +describe("useLabelCounts", () => { + beforeEach(() => { + mailbox.reset(); + vi.clearAllMocks(); + }); + + it("refreshes when the active account mailbox changes", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + renderHook(() => useLabelCounts({ emailAccountId: "account-1" }), { + wrapper: createWrapper(fetcher), + }); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + + act(() => mailbox.emit("account-2")); + expect(fetcher).toHaveBeenCalledOnce(); + + act(() => mailbox.emit("account-1")); + await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2)); + }); + + it("updates the inbox unread count without waiting for revalidation", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + + act(() => { + result.current.adjustInboxUnread(-1); + }); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + expect(fetcher).toHaveBeenCalledOnce(); + }); + + it("applies an unread delta queued before counts load", async () => { + let resolveResponse: + | ((response: typeof initialResponse) => void) + | undefined; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + resolveResponse = resolve; + }), + ); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => expect(resolveResponse).toBeTypeOf("function")); + act(() => result.current.adjustInboxUnread(-1)); + await act(async () => resolveResponse?.(initialResponse)); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + }); + + it("retains an unread delta when a partial response omits the inbox", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ counts: [], partial: true }) + .mockResolvedValue(initialResponse); + const { result } = renderHook( + () => useLabelCounts({ emailAccountId: "account-1" }), + { wrapper: createWrapper(fetcher) }, + ); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + act(() => result.current.adjustInboxUnread(-1)); + act(() => mailbox.emit("account-1")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(3), + ); + }); + + it("discards a pending unread delta when the account changes", async () => { + const fetcher = vi + .fn() + .mockResolvedValueOnce({ counts: [], partial: true }) + .mockResolvedValueOnce(initialResponse); + const { result, rerender } = renderHook( + ({ emailAccountId }) => useLabelCounts({ emailAccountId }), + { + initialProps: { emailAccountId: "account-1" }, + wrapper: createWrapper(fetcher), + }, + ); + + await waitFor(() => expect(fetcher).toHaveBeenCalledOnce()); + act(() => result.current.adjustInboxUnread(-1)); + rerender({ emailAccountId: "account-2" }); + act(() => mailbox.emit("account-2")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + + rerender({ emailAccountId: "account-1" }); + act(() => mailbox.emit("account-1")); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + + it("ignores an unread update resumed from the previous account", async () => { + const fetcher = vi.fn().mockResolvedValue(initialResponse); + const { result, rerender } = renderHook( + ({ emailAccountId }) => useLabelCounts({ emailAccountId }), + { + initialProps: { emailAccountId: "account-1" }, + wrapper: createWrapper(fetcher), + }, + ); + + await waitFor(() => + expect(result.current.countsById.get("INBOX")?.unread).toBe(4), + ); + const previousAccountAdjustInboxUnread = result.current.adjustInboxUnread; + rerender({ emailAccountId: "account-2" }); + act(() => previousAccountAdjustInboxUnread(-1)); + + expect(result.current.countsById.get("INBOX")?.unread).toBe(4); + }); +}); + +function createWrapper(fetcher: () => unknown) { + const Wrapper = ({ children }: PropsWithChildren) => ( + new Map(), + shouldRetryOnError: false, + }} + > + {children} + + ); + + return Wrapper; +} diff --git a/apps/web/hooks/useLabelCounts.ts b/apps/web/hooks/useLabelCounts.ts index 83ff2e5718..f1aee893f8 100644 --- a/apps/web/hooks/useLabelCounts.ts +++ b/apps/web/hooks/useLabelCounts.ts @@ -1,16 +1,79 @@ -import { useMemo } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; import useSWR from "swr"; import type { LabelCountsResponse } from "@/app/api/labels/counts/route"; +import { subscribeToMailboxStore } from "@/utils/email-cache/mailbox"; +import { GmailLabel } from "@/utils/gmail/label"; /** * Unread/total counts per label, Gmail category, or Outlook folder. * Deliberately not blocking: the sidebar renders without counts and fills in. */ -export function useLabelCounts() { +export function useLabelCounts({ emailAccountId }: { emailAccountId: string }) { const { data, error, isLoading, mutate } = useSWR( "/api/labels/counts", { shouldRetryOnError: false }, ); + const dataRef = useRef(data); + const pendingInboxUnread = useRef({ emailAccountId, delta: 0 }); + dataRef.current = data; + + useLayoutEffect(() => { + pendingInboxUnread.current = { emailAccountId, delta: 0 }; + }, [emailAccountId]); + + useEffect( + () => + subscribeToMailboxStore((changedAccountId) => { + if (changedAccountId === emailAccountId) mutate(); + }), + [emailAccountId, mutate], + ); + + const applyPendingInboxUnreadDelta = useCallback(() => { + mutate( + (current) => { + if ( + pendingInboxUnread.current.emailAccountId !== emailAccountId || + !pendingInboxUnread.current.delta || + !current?.counts.some((count) => count.id === GmailLabel.INBOX) + ) { + return current; + } + const delta = pendingInboxUnread.current.delta; + pendingInboxUnread.current.delta = 0; + return applyInboxUnreadDelta(current, delta); + }, + { revalidate: false }, + ); + }, [emailAccountId, mutate]); + + useEffect(() => { + if (!data || !pendingInboxUnread.current.delta) return; + applyPendingInboxUnreadDelta(); + }, [applyPendingInboxUnreadDelta, data]); + + const adjustInboxUnread = useCallback( + (delta: number) => { + if ( + !delta || + pendingInboxUnread.current.emailAccountId !== emailAccountId + ) { + return; + } + pendingInboxUnread.current.delta += delta; + if (!dataRef.current) { + return; + } + applyPendingInboxUnreadDelta(); + }, + [applyPendingInboxUnreadDelta, emailAccountId], + ); const countsById = useMemo( () => new Map((data?.counts ?? []).map((count) => [count.id, count])), @@ -23,5 +86,21 @@ export function useLabelCounts() { isLoading, error, mutate, + adjustInboxUnread, + }; +} + +function applyInboxUnreadDelta( + current: LabelCountsResponse | undefined, + delta: number, +) { + if (!current) return current; + return { + ...current, + counts: current.counts.map((count) => + count.id === GmailLabel.INBOX + ? { ...count, unread: Math.max(0, count.unread + delta) } + : count, + ), }; } diff --git a/apps/web/utils/email-cache/mailbox.ts b/apps/web/utils/email-cache/mailbox.ts index 971ac6001e..bfa1a9994a 100644 --- a/apps/web/utils/email-cache/mailbox.ts +++ b/apps/web/utils/email-cache/mailbox.ts @@ -433,7 +433,9 @@ export function subscribeToMailboxStore( listener: (emailAccountId: string) => void, ) { mailboxListeners.add(listener); - return () => mailboxListeners.delete(listener); + return () => { + mailboxListeners.delete(listener); + }; } export function notifyMailboxStoreChange(emailAccountId: string) { @@ -577,6 +579,7 @@ function toListMessage(message: ParsedMessage) { date: message.date, internalDate: message.internalDate, labelIds: message.labelIds, + parentFolderId: message.parentFolderId, headers: message.headers, }; } diff --git a/apps/web/utils/threads/load.ts b/apps/web/utils/threads/load.ts index 3a91392063..3e6c85270a 100644 --- a/apps/web/utils/threads/load.ts +++ b/apps/web/utils/threads/load.ts @@ -116,6 +116,7 @@ export function toListThreads({ threads, nextPageToken }: LoadedThreads) { date: message.date, internalDate: message.internalDate, labelIds: message.labelIds, + parentFolderId: message.parentFolderId, headers: message.headers, })), })),