-
Notifications
You must be signed in to change notification settings - Fork 298
Expand file tree
/
Copy pathuseMarkRead.ts
More file actions
153 lines (135 loc) Β· 5.72 KB
/
Copy pathuseMarkRead.ts
File metadata and controls
153 lines (135 loc) Β· 5.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import { useCallback, useEffect, useRef } from 'react';
import { useChannel, useChatContext } from '../../../context';
import { useMessagePaginator } from '../../../hooks';
import { useThreadContext } from '../../Threads';
import type { Channel, Event } from 'stream-chat';
const hasReadLastMessage = (channel: Channel, userId: string) => {
const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id;
const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id;
return latestMessageIdInChannel === lastReadMessageIdServer;
};
type UseMarkReadParams = {
hasMoreNewer: boolean;
isMessageListScrolledToBottom: boolean;
// todo: remove and infer only from useThreadContext return value - if undefined, not a thread list
messageListIsThread: boolean;
};
/**
* 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) => {
const { client } = useChatContext();
const channel = useChannel();
const thread = useThreadContext();
const messagePaginator = useMessagePaginator();
const isThreadList = !!thread || messageListIsThread;
const markRead = useCallback(() => {
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 = () => {
const wasMarkedUnread =
!!messagePaginator.unreadStateSnapshot.getLatestValue().firstUnreadMessageId;
return (
!document.hidden &&
!wasMarkedUnread &&
isMessageListScrolledToBottom &&
(isThreadList
? (thread?.ownUnreadCount ?? 0) > 0
: !!client.user?.id && !hasReadLastMessage(channel, client.user.id))
);
};
const wasAtBottom = wasAtBottomRef.current;
wasAtBottomRef.current = isMessageListScrolledToBottom;
if (wasAtBottom && !isMessageListScrolledToBottom) {
hasLeftBottomRef.current = true;
}
const scrolledBackToBottom =
!wasAtBottom && isMessageListScrolledToBottom && hasLeftBottomRef.current;
const onVisibilityChange = () => {
if (shouldMarkRead()) {
resetUnreadSnapshot();
markRead();
}
};
const handleMessageNew = (event: Event) => {
const threadUpdated = !!thread && event.message?.parent_id === thread.id;
const mainChannelUpdated =
!event.message?.parent_id || event.message?.show_in_channel;
const activeCollectionUpdated = isThreadList ? threadUpdated : mainChannelUpdated;
if (!activeCollectionUpdated) return;
// MERGE-RECONCILE: this is the fix/prevent-unread-indicator-threads scenario
// (this branch's origin). Master's manual setChannelUnreadUiState mechanism was
// removed by PR #2909; unread state is now driven by messagePaginator.
// 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();
}
};
channel.on('message.new', handleMessageNew);
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();
}
return () => {
channel.off('message.new', handleMessageNew);
document.removeEventListener('visibilitychange', onVisibilityChange);
};
}, [
channel,
client,
isMessageListScrolledToBottom,
markRead,
resetUnreadSnapshot,
isThreadList,
messagePaginator,
thread,
]);
};