Files
multica/apps/mobile/lib/unread-counts.ts
Jiayuan Zhang e10bb3fff0 fix(chat): unify unread badge counting across sidebar, mobile, and thread list (MUL-4286) (#5239)
The aggregate chat unread badge used two different definitions: web/
desktop's sidebar summed unread messages while mobile's tab badge (and
the since-removed ChatFab badge it mirrored) counted sessions — the same
account state showed e.g. 5 on web and 2 on iOS. The sidebar also summed
ALL sessions while the thread list zeroes the row being viewed, so a
reply landing in the open conversation flashed a sidebar count with no
matching row.

- packages/core/chat/unread.ts: countUnreadChatMessages() as the single
  shared definition (IM-style message total, optional viewed-session
  exclusion); pure so mobile can import it.
- app-sidebar: use the helper and exclude the actively-viewed session,
  but only while a chat surface is actually showing it (chat route or
  floating window open) — a remembered selection with both surfaces
  closed still counts, since nothing will auto mark-read there.
- mobile: tab badge switches to the shared message count (99+ cap like
  the sidebar), ChatSessionSchema parses unread_count, and markRead's
  optimistic patch zeroes unread_count alongside has_unread.

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-11 02:46:12 +08:00

60 lines
2.5 KiB
TypeScript

/**
* Unread count hooks for the bottom tab bar badges.
*
* Mirrors the counting logic from:
* - packages/core/inbox/queries.ts::useInboxUnreadCount (inbox)
* - packages/core/chat/unread.ts::countUnreadChatMessages (chat — the
* shared pure function IS the definition; web's sidebar calls the same
* one, so the platforms cannot drift apart)
*
* Both queries (`inboxListOptions`, `chatSessionsOptions`) are already kept
* fresh by listing-level realtime hooks mounted in
* `app/(app)/[workspace]/_layout.tsx`, so these hooks only attach a `select`
* to derive a scalar count — re-rendering the tab layout only when the
* number actually changes (TQ compares select output with Object.is).
*
* Behavioral parity (apps/mobile/CLAUDE.md "Counts and visibility must agree"):
* the N rendered here MUST equal the N web shows for the same user/workspace.
*/
import { useQuery } from "@tanstack/react-query";
import { countUnreadChatMessages } from "@multica/core/chat/unread";
import { inboxListOptions } from "@/data/queries/inbox";
import { chatSessionsOptions } from "@/data/queries/chat";
import { deduplicateInboxItems } from "@/lib/inbox-display";
/**
* Unread inbox count, aligned with what the inbox list renders: archived
* items dropped, then deduplicated by issue (one entry per issue), then
* filtered to unread. Same definition as web's sidebar badge.
*/
export function useInboxUnreadCount(wsId: string | null | undefined): number {
const { data } = useQuery({
...inboxListOptions(wsId ?? null),
select: (items) =>
deduplicateInboxItems(items).filter((i) => !i.read).length,
});
return data ?? 0;
}
/**
* Total unread assistant *messages* across chat sessions (IM-style), the
* same number web/desktop's sidebar Chat badge shows. Was a session count
* before MUL-4286; that matched the (since removed) web ChatFab badge and
* disagreed with the sidebar.
*
* No excludeSessionId here: the chat tab renders the active conversation
* itself, and the focused-screen auto mark-read (chat.tsx) plus the
* mutation's optimistic unread_count reset clear that session's share of
* the badge immediately — a badge decrementing on the tab you are already
* inside is normal IM behavior, not a phantom.
*/
export function useChatUnreadMessageCount(
wsId: string | null | undefined,
): number {
const { data } = useQuery({
...chatSessionsOptions(wsId ?? null),
select: (sessions) => countUnreadChatMessages(sessions),
});
return data ?? 0;
}