@@ -86,7 +86,10 @@ export function useChannelUnreadState({
8686 // the marker for such channels to avoid that visible contradiction. The flag
8787 // is cleared on re-open (a fresh snapshot is recomputed for the channel).
8888 const forcedUnreadRef = React . useRef ( new Set < string > ( ) ) ;
89- const [ , forceUnreadRender ] = React . useReducer ( ( n : number ) => n + 1 , 0 ) ;
89+ const [ forcedUnreadVersion , forceUnreadRender ] = React . useReducer (
90+ ( n : number ) => n + 1 ,
91+ 0 ,
92+ ) ;
9093 // Per-message analog of forcedUnreadRef (LP4 v3 mark-unread). A monotonic
9194 // grow-only msg:<id> marker cannot move the read-line backward, so a
9295 // deliberate mark-unread lives in this session-local set, read ONLY as an
@@ -147,6 +150,10 @@ export function useChannelUnreadState({
147150 ( ) => buildCreatedAtByMessageId ( timelineMessages ) ,
148151 [ timelineMessages ] ,
149152 ) ;
153+ const messageById = React . useMemo (
154+ ( ) => new Map ( timelineMessages . map ( ( message ) => [ message . id , message ] ) ) ,
155+ [ timelineMessages ] ,
156+ ) ;
150157 const threadPanelIndex = React . useMemo (
151158 ( ) => buildThreadPanelIndex ( timelineMessages ) ,
152159 [ timelineMessages ] ,
@@ -291,7 +298,7 @@ export function useChannelUnreadState({
291298 // unread descendant with no separate expanded-subtree gate. readStateVersion
292299 // is an intentional recompute trigger so the counts re-read after any marker
293300 // advances.
294- // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
301+ // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
295302 const threadReplyUnreadCounts = React . useMemo (
296303 ( ) =>
297304 openThreadHeadId
@@ -315,6 +322,7 @@ export function useChannelUnreadState({
315322 currentPubkey ,
316323 isMsgForcedUnread ,
317324 readStateVersion ,
325+ forcedUnreadVersion ,
318326 ] ,
319327 ) ;
320328 // Per-thread unread counts for the main-timeline summary rows. Unread is
@@ -323,7 +331,7 @@ export function useChannelUnreadState({
323331 // the parent resolver, so reading an ancestor never clears a descendant
324332 // (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
325333 // trigger so the badge re-reads after any marker advances.
326- // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion is the intentional recompute trigger
334+ // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
327335 const threadUnreadCounts = React . useMemo (
328336 ( ) =>
329337 computeThreadBadgeCounts (
@@ -342,6 +350,41 @@ export function useChannelUnreadState({
342350 isThreadMuted ,
343351 isMsgForcedUnread ,
344352 readStateVersion ,
353+ forcedUnreadVersion ,
354+ ] ,
355+ ) ;
356+
357+ // Per-message unread predicate for the mark-read/unread menu toggle. Reuses
358+ // computeThreadUnreadMarker — the exact function the badge counts call
359+ // (computeThreadBadgeCounts) — over a single-message array, so the menu label
360+ // and the badge can never disagree: one source of truth, no re-derived
361+ // predicate to drift. A message absent from the timeline (never loaded) is
362+ // treated as read, matching the badge, which only tallies loaded messages.
363+ // readStateVersion recomputes on marker advances; forcedUnreadVersion bumps
364+ // on every mark-read/unread so the callback identity changes and the value
365+ // re-flows through the memoized message subtree (forcedUnreadMsgRef is a ref,
366+ // invisible to React on its own). Both keep the menu label and the badge —
367+ // which read the same computeThreadUnreadMarker predicate — from drifting.
368+ // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
369+ const isMessageUnread = React . useCallback (
370+ ( messageId : string ) : boolean => {
371+ const message = messageById . get ( messageId ) ;
372+ if ( ! message ) return false ;
373+ const { firstUnreadReplyId } = computeThreadUnreadMarker (
374+ [ message ] ,
375+ getMessageReadAt ,
376+ currentPubkey ,
377+ isMsgForcedUnread ,
378+ ) ;
379+ return firstUnreadReplyId !== null ;
380+ } ,
381+ [
382+ messageById ,
383+ getMessageReadAt ,
384+ currentPubkey ,
385+ isMsgForcedUnread ,
386+ readStateVersion ,
387+ forcedUnreadVersion ,
345388 ] ,
346389 ) ;
347390
@@ -427,6 +470,7 @@ export function useChannelUnreadState({
427470 handleMarkMessageRead,
428471 handleMarkMessageUnread,
429472 handleMarkUnread,
473+ isMessageUnread,
430474 markRevealedRepliesRead,
431475 openThreadHeadMessage,
432476 threadFirstUnreadReplyId,
0 commit comments