Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 11 additions & 2 deletions src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
messageFocusSignalSelector,
);
const focusedMessageId = messageFocusSignal?.messageId;
const focusToken = messageFocusSignal?.token;

const {
hasNewMessages,
Expand All @@ -152,6 +153,7 @@ const MessageListWithContext = (props: MessageListWithContextProps) => {
});

useMarkRead({
hasMoreNewer,
isMessageListScrolledToBottom,
messageListIsThread: isThreadList,
});
Expand Down Expand Up @@ -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();

Expand Down
7 changes: 2 additions & 5 deletions src/components/MessageList/UnreadMessagesNotification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
>
Expand Down
10 changes: 9 additions & 1 deletion src/components/MessageList/VirtualizedMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ const VirtualizedMessageListWithContext = (
unreadStateSnapshotSelector,
);
const focusedMessageId = messageFocusSignal?.messageId;
const focusToken = messageFocusSignal?.token;

const virtuoso = useRef<VirtuosoHandle>(null);

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

useMarkRead({
hasMoreNewer,
isMessageListScrolledToBottom,
messageListIsThread: isThreadList,
});
Expand Down Expand Up @@ -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();

Expand Down
90 changes: 64 additions & 26 deletions src/components/MessageList/hooks/useMarkRead.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) => {
Expand All @@ -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 = () => {
Expand All @@ -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) => {
Expand All @@ -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();
}
};
Expand All @@ -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();
}

Expand All @@ -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;
// }
Loading