1- import { useCallback , useEffect } from 'react' ;
1+ import { useCallback , useEffect , useRef } from 'react' ;
22import { useChannel , useChatContext } from '../../../context' ;
33import { useMessagePaginator } from '../../../hooks' ;
44import { useThreadContext } from '../../Threads' ;
@@ -11,18 +11,19 @@ const hasReadLastMessage = (channel: Channel, userId: string) => {
1111} ;
1212
1313type UseMarkReadParams = {
14+ hasMoreNewer : boolean ;
1415 isMessageListScrolledToBottom : boolean ;
1516 // todo: remove and infer only from useThreadContext return value - if undefined, not a thread list
1617 messageListIsThread : boolean ;
1718} ;
1819
1920/**
20- * Takes care of marking the active message collection read (channel or thread).
21- * The collection is marked read only if:
22- * 1. the list is scrolled to the bottom
23- * 2. it was not explicitly marked unread by the user
21+ * Marks the active message collection (channel or thread) read when the user is caught up at the
22+ * bottom, and keeps the paginator's unread snapshot (the "Unread messages" separator / "N new"
23+ * banner) in sync.
2424 */
2525export const useMarkRead = ( {
26+ hasMoreNewer,
2627 isMessageListScrolledToBottom,
2728 messageListIsThread,
2829} : UseMarkReadParams ) => {
@@ -34,13 +35,46 @@ export const useMarkRead = ({
3435 const isThreadList = ! ! thread || messageListIsThread ;
3536
3637 const markRead = useCallback ( ( ) => {
37- if ( thread ) {
38- client . messageDeliveryReporter . throttledMarkRead ( thread ) ;
39- return ;
40- }
41- client . messageDeliveryReporter . throttledMarkRead ( channel ) ;
38+ client . messageDeliveryReporter . throttledMarkRead ( thread ?? channel ) ;
4239 } , [ channel , client . messageDeliveryReporter , thread ] ) ;
4340
41+ // Advance the frozen unread snapshot the separator/banner render from. The LLC never clears it on
42+ // `message.read`, so on a genuine catch-up we clear it ourselves; deliberately NOT called on the
43+ // initial open (see the effect below) so the separator persists where the user left off.
44+ const resetUnreadSnapshot = useCallback ( ( ) => {
45+ const loadedItems = messagePaginator . state . getLatestValue ( ) . items ?? [ ] ;
46+ const previous = messagePaginator . unreadStateSnapshot . getLatestValue ( ) ;
47+ messagePaginator . unreadStateSnapshot . next ( {
48+ ...previous ,
49+ firstUnreadMessageId : null ,
50+ lastReadAt : new Date ( ) ,
51+ lastReadMessageId :
52+ loadedItems [ loadedItems . length - 1 ] ?. id ?? previous . lastReadMessageId ,
53+ unreadCount : 0 ,
54+ } ) ;
55+ } , [ messagePaginator ] ) ;
56+
57+ useEffect ( ( ) => {
58+ // Tell the state layer whether the user is actively viewing the latest messages (tab
59+ // foregrounded AND at the bottom AND no newer messages beyond the loaded window). While live,
60+ // the LLC skips the own-unread bump on `message.new` so the separator/banner never flash.
61+ const pushViewingLive = ( ) =>
62+ messagePaginator . setViewingLive (
63+ ! document . hidden && isMessageListScrolledToBottom && ! hasMoreNewer ,
64+ ) ;
65+
66+ pushViewingLive ( ) ;
67+ document . addEventListener ( 'visibilitychange' , pushViewingLive ) ;
68+
69+ return ( ) => {
70+ document . removeEventListener ( 'visibilitychange' , pushViewingLive ) ;
71+ messagePaginator . setViewingLive ( false ) ;
72+ } ;
73+ } , [ hasMoreNewer , isMessageListScrolledToBottom , messagePaginator ] ) ;
74+
75+ const wasAtBottomRef = useRef ( isMessageListScrolledToBottom ) ;
76+ const hasLeftBottomRef = useRef ( false ) ;
77+
4478 useEffect ( ( ) => {
4579 if ( ! channel . getConfig ( ) ?. read_events ) return ;
4680 const shouldMarkRead = ( ) => {
@@ -56,8 +90,19 @@ export const useMarkRead = ({
5690 ) ;
5791 } ;
5892
93+ const wasAtBottom = wasAtBottomRef . current ;
94+ wasAtBottomRef . current = isMessageListScrolledToBottom ;
95+ if ( wasAtBottom && ! isMessageListScrolledToBottom ) {
96+ hasLeftBottomRef . current = true ;
97+ }
98+ const scrolledBackToBottom =
99+ ! wasAtBottom && isMessageListScrolledToBottom && hasLeftBottomRef . current ;
100+
59101 const onVisibilityChange = ( ) => {
60- if ( shouldMarkRead ( ) ) markRead ( ) ;
102+ if ( shouldMarkRead ( ) ) {
103+ resetUnreadSnapshot ( ) ;
104+ markRead ( ) ;
105+ }
61106 } ;
62107
63108 const handleMessageNew = ( event : Event ) => {
@@ -73,6 +118,7 @@ export const useMarkRead = ({
73118 // unreadStateSnapshot, and the thread-vs-channel guard above. Verify at runtime
74119 // that thread replies do NOT bump the channel's unread UI under the paginator model.
75120 if ( shouldMarkRead ( ) ) {
121+ resetUnreadSnapshot ( ) ;
76122 markRead ( ) ;
77123 }
78124 } ;
@@ -81,6 +127,12 @@ export const useMarkRead = ({
81127 document . addEventListener ( 'visibilitychange' , onVisibilityChange ) ;
82128
83129 if ( shouldMarkRead ( ) ) {
130+ // On open (no scroll-back) mark read on the SERVER only and keep the snapshot so the
131+ // separator/banner persist; on a real scroll-back-to-bottom also clear the snapshot.
132+ if ( scrolledBackToBottom ) {
133+ resetUnreadSnapshot ( ) ;
134+ hasLeftBottomRef . current = false ;
135+ }
84136 markRead ( ) ;
85137 }
86138
@@ -93,23 +145,9 @@ export const useMarkRead = ({
93145 client ,
94146 isMessageListScrolledToBottom ,
95147 markRead ,
148+ resetUnreadSnapshot ,
96149 isThreadList ,
97150 messagePaginator ,
98151 thread ,
99152 ] ) ;
100153} ;
101-
102- // todo: remove?
103- // function getPreviousLastMessage(messages: LocalMessage[], newMessage?: MessageResponse) {
104- // if (!newMessage) return;
105- // let previousLastMessage;
106- // for (let i = messages.length - 1; i >= 0; i--) {
107- // const msg = messages[i];
108- // if (!msg?.id) break;
109- // if (msg.id !== newMessage.id) {
110- // previousLastMessage = msg;
111- // break;
112- // }
113- // }
114- // return previousLastMessage;
115- // }
0 commit comments