diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 4817f500e..36bf68a7d 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -341,6 +341,13 @@ const ChannelInner = ( originalTitle.current = document.title; if (!errored) { + // Re-derive the unread snapshot from the current read state on every (re)open. A cached + // channel is NOT re-queried on reopen, so the LLC's first-page-query auto-seed does not run + // and the separator/"N new" banner would otherwise show a stale boundary (or never clear). + // Must run before markChannelRead below so it captures the unread boundary before the + // server read advances it. + channel.messagePaginator.seedUnreadSnapshot(); + const ownReadState = client.userID ? channel.state.read[client.userID] : undefined; diff --git a/src/components/MessageList/MessageList.tsx b/src/components/MessageList/MessageList.tsx index 7055599bf..0cd6671a2 100644 --- a/src/components/MessageList/MessageList.tsx +++ b/src/components/MessageList/MessageList.tsx @@ -129,6 +129,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { messageFocusSignalSelector, ); const focusedMessageId = messageFocusSignal?.messageId; + const focusToken = messageFocusSignal?.token; const { hasNewMessages, @@ -152,6 +153,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { }); useMarkRead({ + hasMoreNewer, isMessageListScrolledToBottom, messageListIsThread: isThreadList, }); @@ -245,8 +247,15 @@ const MessageListWithContext = (props: MessageListWithContextProps) => { React.useLayoutEffect(() => { if (!focusedMessageId) return; const element = listElement?.querySelector(`[data-message-id='${focusedMessageId}']`); - element?.scrollIntoView({ block: 'center' }); - }, [focusedMessageId, listElement]); + if (!element) return; + element.scrollIntoView({ block: 'center' }); + messagePaginator.scheduleMessageFocusSignalClear({ token: focusToken }); + }, [focusedMessageId, focusToken, listElement, messagePaginator]); + + React.useEffect(() => { + const paginator = messagePaginator; + return () => paginator.clearMessageFocusSignal(); + }, [messagePaginator]); const id = useStableId(); diff --git a/src/components/MessageList/UnreadMessagesNotification.tsx b/src/components/MessageList/UnreadMessagesNotification.tsx index 0c88f0cdf..1fd04d742 100644 --- a/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/src/components/MessageList/UnreadMessagesNotification.tsx @@ -66,11 +66,8 @@ export const UnreadMessagesNotification = ({ appearance='outline' aria-label={t('aria/Mark messages as read')} onClick={() => { - if (thread) { - client.messageDeliveryReporter.throttledMarkRead(thread); - return; - } - client.messageDeliveryReporter.throttledMarkRead(channel); + messagePaginator.clearUnreadSnapshot(); + client.messageDeliveryReporter.throttledMarkRead(thread ?? channel); }} variant='secondary' > diff --git a/src/components/MessageList/VirtualizedMessageList.tsx b/src/components/MessageList/VirtualizedMessageList.tsx index fe0fa0603..220582ded 100644 --- a/src/components/MessageList/VirtualizedMessageList.tsx +++ b/src/components/MessageList/VirtualizedMessageList.tsx @@ -293,6 +293,7 @@ const VirtualizedMessageListWithContext = ( unreadStateSnapshotSelector, ); const focusedMessageId = messageFocusSignal?.messageId; + const focusToken = messageFocusSignal?.token; const virtuoso = useRef(null); @@ -406,6 +407,7 @@ const VirtualizedMessageListWithContext = ( } = useNewMessageNotification(processedMessages, client.userID); useMarkRead({ + hasMoreNewer, isMessageListScrolledToBottom, messageListIsThread: isThreadList, }); @@ -514,13 +516,19 @@ const VirtualizedMessageListWithContext = ( if (index !== -1) { scrollTimeout = setTimeout(() => { virtuoso.current?.scrollToIndex({ align: 'center', index }); + messagePaginator.scheduleMessageFocusSignalClear({ token: focusToken }); }, 0); } } return () => { clearTimeout(scrollTimeout); }; - }, [focusedMessageId, processedMessages]); + }, [focusedMessageId, focusToken, processedMessages, messagePaginator]); + + useEffect(() => { + const paginator = messagePaginator; + return () => paginator.clearMessageFocusSignal(); + }, [messagePaginator]); const id = useStableId(); diff --git a/src/components/MessageList/hooks/useMarkRead.ts b/src/components/MessageList/hooks/useMarkRead.ts index 99fc86061..bade4ca83 100644 --- a/src/components/MessageList/hooks/useMarkRead.ts +++ b/src/components/MessageList/hooks/useMarkRead.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useChannel, useChatContext } from '../../../context'; import { useMessagePaginator } from '../../../hooks'; import { useThreadContext } from '../../Threads'; @@ -11,18 +11,19 @@ const hasReadLastMessage = (channel: Channel, userId: string) => { }; type UseMarkReadParams = { + hasMoreNewer: boolean; isMessageListScrolledToBottom: boolean; // todo: remove and infer only from useThreadContext return value - if undefined, not a thread list messageListIsThread: boolean; }; /** - * Takes care of marking the active message collection read (channel or thread). - * The collection is marked read only if: - * 1. the list is scrolled to the bottom - * 2. it was not explicitly marked unread by the user + * Marks the active message collection (channel or thread) read when the user is caught up at the + * bottom, and keeps the paginator's unread snapshot (the "Unread messages" separator / "N new" + * banner) in sync. */ export const useMarkRead = ({ + hasMoreNewer, isMessageListScrolledToBottom, messageListIsThread, }: UseMarkReadParams) => { @@ -34,13 +35,46 @@ export const useMarkRead = ({ const isThreadList = !!thread || messageListIsThread; const markRead = useCallback(() => { - if (thread) { - client.messageDeliveryReporter.throttledMarkRead(thread); - return; - } - client.messageDeliveryReporter.throttledMarkRead(channel); + client.messageDeliveryReporter.throttledMarkRead(thread ?? channel); }, [channel, client.messageDeliveryReporter, thread]); + // Advance the frozen unread snapshot the separator/banner render from. The LLC never clears it on + // `message.read`, so on a genuine catch-up we clear it ourselves; deliberately NOT called on the + // initial open (see the effect below) so the separator persists where the user left off. + const resetUnreadSnapshot = useCallback(() => { + const loadedItems = messagePaginator.state.getLatestValue().items ?? []; + const previous = messagePaginator.unreadStateSnapshot.getLatestValue(); + messagePaginator.unreadStateSnapshot.next({ + ...previous, + firstUnreadMessageId: null, + lastReadAt: new Date(), + lastReadMessageId: + loadedItems[loadedItems.length - 1]?.id ?? previous.lastReadMessageId, + unreadCount: 0, + }); + }, [messagePaginator]); + + useEffect(() => { + // Tell the state layer whether the user is actively viewing the latest messages (tab + // foregrounded AND at the bottom AND no newer messages beyond the loaded window). While live, + // the LLC skips the own-unread bump on `message.new` so the separator/banner never flash. + const pushViewingLive = () => + messagePaginator.setViewingLive( + !document.hidden && isMessageListScrolledToBottom && !hasMoreNewer, + ); + + pushViewingLive(); + document.addEventListener('visibilitychange', pushViewingLive); + + return () => { + document.removeEventListener('visibilitychange', pushViewingLive); + messagePaginator.setViewingLive(false); + }; + }, [hasMoreNewer, isMessageListScrolledToBottom, messagePaginator]); + + const wasAtBottomRef = useRef(isMessageListScrolledToBottom); + const hasLeftBottomRef = useRef(false); + useEffect(() => { if (!channel.getConfig()?.read_events) return; const shouldMarkRead = () => { @@ -56,8 +90,19 @@ export const useMarkRead = ({ ); }; + const wasAtBottom = wasAtBottomRef.current; + wasAtBottomRef.current = isMessageListScrolledToBottom; + if (wasAtBottom && !isMessageListScrolledToBottom) { + hasLeftBottomRef.current = true; + } + const scrolledBackToBottom = + !wasAtBottom && isMessageListScrolledToBottom && hasLeftBottomRef.current; + const onVisibilityChange = () => { - if (shouldMarkRead()) markRead(); + if (shouldMarkRead()) { + resetUnreadSnapshot(); + markRead(); + } }; const handleMessageNew = (event: Event) => { @@ -73,6 +118,7 @@ export const useMarkRead = ({ // unreadStateSnapshot, and the thread-vs-channel guard above. Verify at runtime // that thread replies do NOT bump the channel's unread UI under the paginator model. if (shouldMarkRead()) { + resetUnreadSnapshot(); markRead(); } }; @@ -81,6 +127,12 @@ export const useMarkRead = ({ document.addEventListener('visibilitychange', onVisibilityChange); if (shouldMarkRead()) { + // On open (no scroll-back) mark read on the SERVER only and keep the snapshot so the + // separator/banner persist; on a real scroll-back-to-bottom also clear the snapshot. + if (scrolledBackToBottom) { + resetUnreadSnapshot(); + hasLeftBottomRef.current = false; + } markRead(); } @@ -93,23 +145,9 @@ export const useMarkRead = ({ client, isMessageListScrolledToBottom, markRead, + resetUnreadSnapshot, isThreadList, messagePaginator, thread, ]); }; - -// todo: remove? -// function getPreviousLastMessage(messages: LocalMessage[], newMessage?: MessageResponse) { -// if (!newMessage) return; -// let previousLastMessage; -// for (let i = messages.length - 1; i >= 0; i--) { -// const msg = messages[i]; -// if (!msg?.id) break; -// if (msg.id !== newMessage.id) { -// previousLastMessage = msg; -// break; -// } -// } -// return previousLastMessage; -// }