Skip to content

Commit 9ebe705

Browse files
committed
fix: unread indicators
1 parent db2f518 commit 9ebe705

5 files changed

Lines changed: 93 additions & 34 deletions

File tree

src/components/Channel/Channel.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,13 @@ const ChannelInner = (
341341
originalTitle.current = document.title;
342342

343343
if (!errored) {
344+
// Re-derive the unread snapshot from the current read state on every (re)open. A cached
345+
// channel is NOT re-queried on reopen, so the LLC's first-page-query auto-seed does not run
346+
// and the separator/"N new" banner would otherwise show a stale boundary (or never clear).
347+
// Must run before markChannelRead below so it captures the unread boundary before the
348+
// server read advances it.
349+
channel.messagePaginator.seedUnreadSnapshot();
350+
344351
const ownReadState = client.userID
345352
? channel.state.read[client.userID]
346353
: undefined;

src/components/MessageList/MessageList.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
129129
messageFocusSignalSelector,
130130
);
131131
const focusedMessageId = messageFocusSignal?.messageId;
132+
const focusToken = messageFocusSignal?.token;
132133

133134
const {
134135
hasNewMessages,
@@ -152,6 +153,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
152153
});
153154

154155
useMarkRead({
156+
hasMoreNewer,
155157
isMessageListScrolledToBottom,
156158
messageListIsThread: isThreadList,
157159
});
@@ -245,8 +247,15 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
245247
React.useLayoutEffect(() => {
246248
if (!focusedMessageId) return;
247249
const element = listElement?.querySelector(`[data-message-id='${focusedMessageId}']`);
248-
element?.scrollIntoView({ block: 'center' });
249-
}, [focusedMessageId, listElement]);
250+
if (!element) return;
251+
element.scrollIntoView({ block: 'center' });
252+
messagePaginator.scheduleMessageFocusSignalClear({ token: focusToken });
253+
}, [focusedMessageId, focusToken, listElement, messagePaginator]);
254+
255+
React.useEffect(() => {
256+
const paginator = messagePaginator;
257+
return () => paginator.clearMessageFocusSignal();
258+
}, [messagePaginator]);
250259

251260
const id = useStableId();
252261

src/components/MessageList/UnreadMessagesNotification.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,8 @@ export const UnreadMessagesNotification = ({
6666
appearance='outline'
6767
aria-label={t('aria/Mark messages as read')}
6868
onClick={() => {
69-
if (thread) {
70-
client.messageDeliveryReporter.throttledMarkRead(thread);
71-
return;
72-
}
73-
client.messageDeliveryReporter.throttledMarkRead(channel);
69+
messagePaginator.clearUnreadSnapshot();
70+
client.messageDeliveryReporter.throttledMarkRead(thread ?? channel);
7471
}}
7572
variant='secondary'
7673
>

src/components/MessageList/VirtualizedMessageList.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,7 @@ const VirtualizedMessageListWithContext = (
293293
unreadStateSnapshotSelector,
294294
);
295295
const focusedMessageId = messageFocusSignal?.messageId;
296+
const focusToken = messageFocusSignal?.token;
296297

297298
const virtuoso = useRef<VirtuosoHandle>(null);
298299

@@ -406,6 +407,7 @@ const VirtualizedMessageListWithContext = (
406407
} = useNewMessageNotification(processedMessages, client.userID);
407408

408409
useMarkRead({
410+
hasMoreNewer,
409411
isMessageListScrolledToBottom,
410412
messageListIsThread: isThreadList,
411413
});
@@ -514,13 +516,19 @@ const VirtualizedMessageListWithContext = (
514516
if (index !== -1) {
515517
scrollTimeout = setTimeout(() => {
516518
virtuoso.current?.scrollToIndex({ align: 'center', index });
519+
messagePaginator.scheduleMessageFocusSignalClear({ token: focusToken });
517520
}, 0);
518521
}
519522
}
520523
return () => {
521524
clearTimeout(scrollTimeout);
522525
};
523-
}, [focusedMessageId, processedMessages]);
526+
}, [focusedMessageId, focusToken, processedMessages, messagePaginator]);
527+
528+
useEffect(() => {
529+
const paginator = messagePaginator;
530+
return () => paginator.clearMessageFocusSignal();
531+
}, [messagePaginator]);
524532

525533
const id = useStableId();
526534

src/components/MessageList/hooks/useMarkRead.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect } from 'react';
1+
import { useCallback, useEffect, useRef } from 'react';
22
import { useChannel, useChatContext } from '../../../context';
33
import { useMessagePaginator } from '../../../hooks';
44
import { useThreadContext } from '../../Threads';
@@ -11,18 +11,19 @@ const hasReadLastMessage = (channel: Channel, userId: string) => {
1111
};
1212

1313
type 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
*/
2525
export 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

Comments
 (0)