fix(inbox): clear unread badge instantly when opening a conversation

The badge used to linger for the duration of the
  click → unread-reset effect → server UPDATE → realtime UPDATE → setState
round-trip — long enough to be noticeable, and visible indefinitely if
the realtime UPDATE was ever dropped. There was also a brief flicker
when a customer sent a new message while the user was already viewing
the thread: the webhook bumps unread_count on the server, the realtime
UPDATE arrives at the client and overwrites the local 0 with a positive
value for the ~100ms it takes the reset effect's server UPDATE to
round-trip back.

Fix in two places:

1. handleSelectConversation (and the deep-link auto-select path)
   optimistically zero the list copy's unread_count immediately. We
   deliberately do *not* touch activeConversation.unread_count — the
   reset effect inside MessageThread keys off that value to decide
   whether to fire the server UPDATE, so zeroing it locally would
   silently skip the persist.

2. handleConversationEvent UPDATE suppresses the incoming unread_count
   for the currently-active conv. Non-active convs take the value
   as-is; the active one stays at 0 until the user navigates away.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-22 09:27:04 +03:00
parent 3de2da6cca
commit 4e3bb707d2
No known key found for this signature in database

View File

@ -235,8 +235,22 @@ export default function InboxPage() {
if (event.eventType === "UPDATE") {
if (knownConvIdsRef.current.has(conv.id)) {
// If this UPDATE is for the conv the user is currently viewing,
// suppress the incoming unread_count — the user is reading it
// RIGHT NOW, so any positive value would just flicker the badge
// back on for the ~100ms it takes for the reset effect's server
// UPDATE to round-trip. Non-active convs take the value as-is.
const isActive = activeConversation?.id === conv.id;
setConversations((prev) =>
prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c)),
prev.map((c) =>
c.id === conv.id
? {
...c,
...conv,
unread_count: isActive ? 0 : conv.unread_count,
}
: c,
),
);
} else {
// UPDATE arrived before the INSERT (or after a missed INSERT)
@ -339,6 +353,17 @@ export default function InboxPage() {
setActiveConversation(match);
setActiveContact(match.contact ?? null);
setMessages([]);
// Mirror the optimistic unread reset that handleSelectConversation
// does — the user just deep-linked into this conv, treat that the
// same as a click. Leaves activeConversation.unread_count alone so
// the MessageThread reset effect still fires the server UPDATE.
if (match.unread_count > 0) {
setConversations((prev) =>
prev.map((c) =>
c.id === match.id ? { ...c, unread_count: 0 } : c,
),
);
}
}
}
},
@ -355,6 +380,22 @@ export default function InboxPage() {
setActiveConversation(conv);
setActiveContact(conv.contact ?? null);
setMessages([]);
// Optimistically clear the unread badge for this conv. The
// server-side reset is fired by the unread-reset effect inside
// MessageThread (which reads activeConversation.unread_count, not
// the list copy — so we deliberately leave that intact below to
// keep the effect firing), and the realtime UPDATE that comes
// back will sync to 0 again as a no-op. Zeroing the list copy
// here means the user sees the badge disappear the instant they
// click instead of waiting for the round-trip — and it persists
// even if the realtime UPDATE is dropped.
setConversations((prev) =>
prev.map((c) =>
c.id === conv.id && c.unread_count > 0
? { ...c, unread_count: 0 }
: c,
),
);
// Record the selection on the deep-link ref BEFORE we change the
// URL. The router.replace below flips `deepLinkConvId`, which can
// in turn cause ConversationList to refetch and eventually call