stream-chat 9.20.3 → 9.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ import type { StreamChat } from '../client';
2
+ import { Channel } from '../channel';
3
+ import type { ThreadUserReadState } from '../thread';
4
+ import { Thread } from '../thread';
5
+ import type {
6
+ EventAPIResponse,
7
+ LocalMessage,
8
+ MarkDeliveredOptions,
9
+ MarkReadOptions,
10
+ } from '../types';
11
+ import { throttle } from '../utils';
12
+
13
+ const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
14
+ const MARK_AS_DELIVERED_BUFFER_TIMEOUT = 1000 as const;
15
+ const MARK_AS_READ_THROTTLE_TIMEOUT = 1000 as const;
16
+
17
+ const isChannel = (item: Channel | Thread): item is Channel => item instanceof Channel;
18
+ const isThread = (item: Channel | Thread): item is Thread => item instanceof Thread;
19
+
20
+ type MessageId = string;
21
+ type ChannelThreadCompositeId = string;
22
+
23
+ export type AnnounceDeliveryOptions = Omit<
24
+ MarkDeliveredOptions,
25
+ 'latest_delivered_messages'
26
+ >;
27
+
28
+ export type MessageDeliveryReporterOptions = {
29
+ client: StreamChat;
30
+ };
31
+
32
+ export class MessageDeliveryReporter {
33
+ protected client: StreamChat;
34
+
35
+ protected deliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> =
36
+ new Map();
37
+ protected nextDeliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> =
38
+ new Map();
39
+
40
+ protected markDeliveredRequestPromise: Promise<EventAPIResponse | void> | null = null;
41
+ protected markDeliveredTimeout: ReturnType<typeof setTimeout> | null = null;
42
+
43
+ constructor({ client }: MessageDeliveryReporterOptions) {
44
+ this.client = client;
45
+ }
46
+
47
+ private get markDeliveredRequestInFlight() {
48
+ return this.markDeliveredRequestPromise !== null;
49
+ }
50
+ private get hasTimer() {
51
+ return this.markDeliveredTimeout !== null;
52
+ }
53
+ private get hasDeliveryCandidates() {
54
+ return this.deliveryReportCandidates.size > 0;
55
+ }
56
+
57
+ /**
58
+ * Build latest_delivered_messages payload from an arbitrary buffer (deliveryReportCandidates / nextDeliveryReportCandidates)
59
+ */
60
+ private confirmationsFrom(map: Map<ChannelThreadCompositeId, MessageId>) {
61
+ return Array.from(map.entries()).map(([key, messageId]) => {
62
+ const [type, id, parent_id] = key.split(':');
63
+ return parent_id
64
+ ? { cid: `${type}:${id}`, id: messageId, parent_id }
65
+ : { cid: key, id: messageId };
66
+ });
67
+ }
68
+
69
+ private confirmationsFromDeliveryReportCandidates() {
70
+ const entries = Array.from(this.deliveryReportCandidates);
71
+ const sendBuffer = new Map(entries.slice(0, MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD));
72
+ this.deliveryReportCandidates = new Map(
73
+ entries.slice(MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD),
74
+ );
75
+
76
+ return { latest_delivered_messages: this.confirmationsFrom(sendBuffer), sendBuffer };
77
+ }
78
+
79
+ /**
80
+ * Generate candidate key for storing in the candidates buffer
81
+ * @param collection
82
+ * @private
83
+ */
84
+ private candidateKeyFor(
85
+ collection: Channel | Thread,
86
+ ): ChannelThreadCompositeId | undefined {
87
+ if (isChannel(collection)) return collection.cid;
88
+ if (isThread(collection)) return `${collection.channel.cid}:${collection.id}`;
89
+ }
90
+
91
+ /**
92
+ * Retrieve the reference to the latest message in the state that is nor read neither reported as delivered
93
+ * @param collection
94
+ */
95
+ private getNextDeliveryReportCandidate = (
96
+ collection: Channel | Thread,
97
+ ): { key: ChannelThreadCompositeId; id: MessageId | null } | undefined => {
98
+ const ownUserId = this.client.user?.id;
99
+ if (!ownUserId) return;
100
+
101
+ let latestMessages: LocalMessage[] = [];
102
+ let lastDeliveredAt: Date | undefined;
103
+ let lastReadAt: Date | undefined;
104
+ let key: string | undefined = undefined;
105
+
106
+ if (isChannel(collection)) {
107
+ latestMessages = collection.state.latestMessages;
108
+ const ownReadState = collection.state.read[ownUserId] ?? {};
109
+ lastReadAt = ownReadState?.last_read;
110
+ lastDeliveredAt = ownReadState?.last_delivered_at;
111
+ key = collection.cid;
112
+ } else if (isThread(collection)) {
113
+ latestMessages = collection.state.getLatestValue().replies;
114
+ const ownReadState =
115
+ collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState);
116
+ lastReadAt = ownReadState?.lastReadAt;
117
+ // @ts-expect-error lastDeliveredAt is not defined yet on ThreadUserReadState
118
+ lastDeliveredAt = ownReadState?.lastDeliveredAt;
119
+ key = `${collection.channel.cid}:${collection.id}`;
120
+ // todo: remove return statement once marking messages as delivered in thread is supported
121
+ return;
122
+ } else {
123
+ return;
124
+ }
125
+
126
+ if (!key) return;
127
+
128
+ const [latestMessage] = latestMessages.slice(-1);
129
+
130
+ const wholeCollectionIsRead =
131
+ !latestMessage || lastReadAt >= latestMessage.created_at;
132
+ if (wholeCollectionIsRead) return { key, id: null };
133
+ const wholeCollectionIsMarkedDelivered =
134
+ !latestMessage || (lastDeliveredAt ?? 0) >= latestMessage.created_at;
135
+ if (wholeCollectionIsMarkedDelivered) return { key, id: null };
136
+
137
+ return { key, id: latestMessage.id || null };
138
+ };
139
+
140
+ /**
141
+ * Updates the delivery candidates buffer with the latest delivery candidates
142
+ * @param collection
143
+ */
144
+ private trackDeliveredCandidate(collection: Channel | Thread) {
145
+ if (isChannel(collection) && !collection.getConfig()?.read_events) return;
146
+ if (isThread(collection) && !collection.channel.getConfig()?.read_events) return;
147
+ const candidate = this.getNextDeliveryReportCandidate(collection);
148
+ if (!candidate?.key) return;
149
+ const buffer = this.markDeliveredRequestInFlight
150
+ ? this.nextDeliveryReportCandidates
151
+ : this.deliveryReportCandidates;
152
+ if (candidate.id === null) buffer.delete(candidate.key);
153
+ else buffer.set(candidate.key, candidate.id);
154
+ }
155
+
156
+ /**
157
+ * Removes candidate from the delivery report buffer
158
+ * @param collection
159
+ * @private
160
+ */
161
+ private removeCandidateFor(collection: Channel | Thread) {
162
+ const candidateKey = this.candidateKeyFor(collection);
163
+ if (!candidateKey) return;
164
+ this.deliveryReportCandidates.delete(candidateKey);
165
+ this.nextDeliveryReportCandidates.delete(candidateKey);
166
+ }
167
+
168
+ /**
169
+ * Records the latest message delivered for Channel or Thread instances and schedules the next report
170
+ * if not already scheduled and candidates exist.
171
+ * Should be used for WS handling (message.new) as well as for ingesting HTTP channel query results.
172
+ * @param collections
173
+ */
174
+ public syncDeliveredCandidates(collections: (Channel | Thread)[]) {
175
+ if (this.client.user?.privacy_settings?.delivery_receipts?.enabled === false) return;
176
+ for (const c of collections) this.trackDeliveredCandidate(c);
177
+ this.announceDeliveryBuffered();
178
+ }
179
+
180
+ /**
181
+ * Fires delivery announcement request followed by immediate delivery candidate buffer reset.
182
+ * @param options
183
+ */
184
+ public announceDelivery = (options?: AnnounceDeliveryOptions) => {
185
+ if (this.markDeliveredRequestInFlight || !this.hasDeliveryCandidates) return;
186
+
187
+ const { latest_delivered_messages, sendBuffer } =
188
+ this.confirmationsFromDeliveryReportCandidates();
189
+ if (!latest_delivered_messages.length) return;
190
+
191
+ const payload = { ...options, latest_delivered_messages };
192
+
193
+ const postFlightReconcile = () => {
194
+ this.markDeliveredRequestPromise = null;
195
+
196
+ // promote anything that arrived during request
197
+ for (const [k, v] of this.nextDeliveryReportCandidates.entries()) {
198
+ this.deliveryReportCandidates.set(k, v);
199
+ }
200
+ this.nextDeliveryReportCandidates = new Map();
201
+
202
+ // checks internally whether there are candidates to announce
203
+ this.announceDeliveryBuffered(options);
204
+ };
205
+
206
+ const handleError = () => {
207
+ // repopulate relevant candidates for the next report
208
+ for (const [k, v] of Object.entries(sendBuffer)) {
209
+ if (!this.deliveryReportCandidates.has(k)) {
210
+ this.deliveryReportCandidates.set(k, v);
211
+ }
212
+ }
213
+ postFlightReconcile();
214
+ };
215
+
216
+ this.markDeliveredRequestPromise = this.client
217
+ .markChannelsDelivered(payload)
218
+ .then(postFlightReconcile, handleError);
219
+ };
220
+
221
+ public announceDeliveryBuffered = (options?: AnnounceDeliveryOptions) => {
222
+ if (this.hasTimer || this.markDeliveredRequestInFlight || !this.hasDeliveryCandidates)
223
+ return;
224
+ this.markDeliveredTimeout = setTimeout(() => {
225
+ this.markDeliveredTimeout = null;
226
+ this.announceDelivery(options);
227
+ }, MARK_AS_DELIVERED_BUFFER_TIMEOUT);
228
+ };
229
+
230
+ /**
231
+ * Delegates the mark-read call to the Channel or Thread instance
232
+ * @param collection
233
+ * @param options
234
+ */
235
+ public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => {
236
+ let result: EventAPIResponse | null = null;
237
+ if (isChannel(collection)) {
238
+ result = await collection.markAsReadRequest(options);
239
+ } else if (isThread(collection)) {
240
+ result = await collection.channel.markAsReadRequest({
241
+ ...options,
242
+ thread_id: collection.id,
243
+ });
244
+ }
245
+
246
+ this.removeCandidateFor(collection);
247
+ return result;
248
+ };
249
+
250
+ /**
251
+ * Throttles the MessageDeliveryReporter.markRead call
252
+ * @param collection
253
+ * @param options
254
+ */
255
+ public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, {
256
+ leading: false,
257
+ trailing: true,
258
+ });
259
+ }
@@ -0,0 +1,417 @@
1
+ import type { ReadResponse, UserResponse } from '../types';
2
+
3
+ type UserId = string;
4
+ type MessageId = string;
5
+ export type MsgRef = { timestampMs: number; msgId: MessageId };
6
+ export type OwnMessageReceiptsTrackerMessageLocator = (
7
+ timestampMs: number,
8
+ ) => MsgRef | null;
9
+ export type UserProgress = {
10
+ user: UserResponse;
11
+ lastReadRef: MsgRef; // MIN_REF if none
12
+ lastDeliveredRef: MsgRef; // MIN_REF if none; always >= readRef
13
+ };
14
+
15
+ // ---------- ordering utilities ----------
16
+
17
+ const MIN_REF: MsgRef = { timestampMs: Number.NEGATIVE_INFINITY, msgId: '' } as const;
18
+
19
+ const compareRefsAsc = (a: MsgRef, b: MsgRef) =>
20
+ a.timestampMs !== b.timestampMs ? a.timestampMs - b.timestampMs : 0;
21
+
22
+ const findIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => {
23
+ let lo = 0,
24
+ hi = arr.length;
25
+ while (lo < hi) {
26
+ const mid = (lo + hi) >>> 1;
27
+ if (compareRefsAsc(keyOf(arr[mid]), target) >= 0) hi = mid;
28
+ else lo = mid + 1;
29
+ }
30
+ return lo;
31
+ };
32
+
33
+ /**
34
+ * For insertion after the last equal item. E.g. array [a] exists and b is being inserted -> we want [a,b], not [b,a].
35
+ * @param arr
36
+ * @param target
37
+ * @param keyOf
38
+ */
39
+ const findUpperIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => {
40
+ let lo = 0,
41
+ hi = arr.length;
42
+ while (lo < hi) {
43
+ const mid = (lo + hi) >>> 1;
44
+ if (compareRefsAsc(keyOf(arr[mid]), target) > 0) hi = mid;
45
+ else lo = mid + 1;
46
+ }
47
+ return lo;
48
+ };
49
+
50
+ const insertByKey = (
51
+ arr: UserProgress[],
52
+ item: UserProgress,
53
+ keyOf: (x: UserProgress) => MsgRef,
54
+ ) => arr.splice(findUpperIndex(arr, keyOf(item), keyOf), 0, item);
55
+
56
+ const removeByOldKey = (
57
+ arr: UserProgress[],
58
+ item: UserProgress,
59
+ oldKey: MsgRef,
60
+ keyOf: (x: UserProgress) => MsgRef,
61
+ ) => {
62
+ // Find the plateau for oldKey, scan to match by user id
63
+ let i = findIndex(arr, oldKey, keyOf);
64
+ while (i < arr.length && compareRefsAsc(keyOf(arr[i]), oldKey) === 0) {
65
+ if (arr[i].user.id === item.user.id) {
66
+ arr.splice(i, 1);
67
+ return;
68
+ }
69
+ i++;
70
+ }
71
+ };
72
+
73
+ export type OwnMessageReceiptsTrackerOptions = {
74
+ locateMessage: OwnMessageReceiptsTrackerMessageLocator;
75
+ };
76
+
77
+ /**
78
+ * MessageReceiptsTracker
79
+ * --------------------------------
80
+ * Tracks **other participants’** delivery/read progress toward **own (outgoing) messages**
81
+ * within a **single timeline** (one channel/thread).
82
+ *
83
+ * How it works
84
+ * ------------
85
+ * - Each user has a compact progress record:
86
+ * - `lastReadRef`: latest message they have **read**
87
+ * - `lastDeliveredRef`: latest message they have **received** (always `>= lastReadRef`)
88
+ * - Internally keeps two arrays sorted **ascending by timestamp**:
89
+ * - `readSorted` (by `lastReadRef`)
90
+ * - `deliveredSorted` (by `lastDeliveredRef`)
91
+ * - Queries like “who read message M?” become a **binary search + suffix slice**.
92
+ *
93
+ * Construction
94
+ * ------------
95
+ * `new MessageReceiptsTracker({locateMessage})`
96
+ * - `locateMessage(timestamp) => MsgRef | null` must resolve a message ref representation - `{ timestamp, msgId }`.
97
+ * - If `locateMessage` returns `null`, the event is ignored (message unknown locally).
98
+ *
99
+ * Event ingestion
100
+ * ---------------
101
+ * - `ingestInitial(rows: ReadResponse[])`: Builds initial state from server snapshot.
102
+ * If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces
103
+ * the invariant `lastDeliveredRef >= lastReadRef`.
104
+ * - `onMessageRead(user, readAtISO)`:
105
+ * Advances the user’s read; also bumps delivered to match if needed.
106
+ * - `onMessageDelivered(user, deliveredAtISO)`:
107
+ * Advances the user’s delivered to `max(currentRead, deliveredAt)`.
108
+ *
109
+ * Queries
110
+ * -------
111
+ * - `readersForMessage(msgRef) : UserResponse[]` → users with `lastReadRef >= msgRef`
112
+ * - `deliveredForMessage(msgRef) : UserResponse[]` → users with `lastDeliveredRef >= msgRef`
113
+ * - `deliveredNotReadForMessage(msgRef): UserResponse[]` → delivered but `lastReadRef < msgRef`
114
+ * - `usersWhoseLastReadIs : UserResponse[]` → users for whom `msgRef` is their *last read* (exact match)
115
+ * - `usersWhoseLastDeliveredIs : UserResponse[]` → users for whom `msgRef` is their *last delivered* (exact match)
116
+ * - `groupUsersByLastReadMessage : Record<MsgId, UserResponse[]> → mapping of messages to their readers
117
+ * - `groupUsersByLastDeliveredMessage : Record<MsgId, UserResponse[]> → mapping of messages to their receivers
118
+ * - `hasUserRead(msgRef, userId) : boolean`
119
+ * - `hasUserDelivered(msgRef, userId) : boolean`
120
+ *
121
+ * Complexity
122
+ * ----------
123
+ * - Update on read/delivered: **O(log U)** (binary search + one splice) per event, where U is count of users stored by tracker.
124
+ * - Query lists: **O(log U + K)** where `K` is the number of returned users (suffix length).
125
+ * - Memory: **O(U)** - tracker’s memory grows linearly with the number of users in the channel/thread and does not depend on the number of messages.
126
+ *
127
+ * Scope & notes
128
+ * -------------
129
+ * - One tracker instance is **scoped to a single timeline**. Instantiate per channel/thread.
130
+ * - Ordering is by **ascending timestamp**; ties are kept stable by inserting at the end of the
131
+ * equal-timestamp plateau (upper-bound insertion), preserving intuitive arrival order.
132
+ * - This tracker models **others’ progress toward own messages**;
133
+ */
134
+ export class MessageReceiptsTracker {
135
+ private byUser = new Map<UserId, UserProgress>();
136
+ private readSorted: UserProgress[] = []; // asc by lastReadRef
137
+ private deliveredSorted: UserProgress[] = []; // asc by lastDeliveredRef
138
+ private locateMessage: OwnMessageReceiptsTrackerMessageLocator;
139
+
140
+ constructor({ locateMessage }: OwnMessageReceiptsTrackerOptions) {
141
+ this.locateMessage = locateMessage;
142
+ }
143
+
144
+ /** Build initial state from server snapshots (single pass + sort). */
145
+ ingestInitial(responses: ReadResponse[]) {
146
+ this.byUser.clear();
147
+ this.readSorted = [];
148
+ this.deliveredSorted = [];
149
+ for (const r of responses) {
150
+ const lastReadTimestamp = r.last_read ? new Date(r.last_read).getTime() : null;
151
+ const lastDeliveredTimestamp = r.last_delivered_at
152
+ ? new Date(r.last_delivered_at).getTime()
153
+ : null;
154
+ const lastReadRef = lastReadTimestamp
155
+ ? (this.locateMessage(lastReadTimestamp) ?? MIN_REF)
156
+ : MIN_REF;
157
+ let lastDeliveredRef = lastDeliveredTimestamp
158
+ ? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF)
159
+ : MIN_REF;
160
+ const isReadAfterDelivered = compareRefsAsc(lastDeliveredRef, lastReadRef) < 0;
161
+ if (isReadAfterDelivered) lastDeliveredRef = lastReadRef;
162
+
163
+ const userProgress: UserProgress = { user: r.user, lastReadRef, lastDeliveredRef };
164
+ this.byUser.set(r.user.id, userProgress);
165
+ this.readSorted.splice(
166
+ findIndex(this.readSorted, lastReadRef, (up) => up.lastReadRef),
167
+ 0,
168
+ userProgress,
169
+ );
170
+ this.deliveredSorted.splice(
171
+ findIndex(this.deliveredSorted, lastDeliveredRef, (up) => up.lastDeliveredRef),
172
+ 0,
173
+ userProgress,
174
+ );
175
+ }
176
+ }
177
+
178
+ /** message.delivered — user device confirmed delivery up to and including messageId. */
179
+ onMessageDelivered({
180
+ user,
181
+ deliveredAt,
182
+ lastDeliveredMessageId,
183
+ }: {
184
+ user: UserResponse;
185
+ deliveredAt: string;
186
+ lastDeliveredMessageId?: string;
187
+ }) {
188
+ const timestampMs = new Date(deliveredAt).getTime();
189
+ const msgRef = lastDeliveredMessageId
190
+ ? { timestampMs, msgId: lastDeliveredMessageId }
191
+ : this.locateMessage(new Date(deliveredAt).getTime());
192
+ if (!msgRef) return;
193
+ const userProgress = this.ensureUser(user);
194
+
195
+ const newDelivered =
196
+ compareRefsAsc(msgRef, userProgress.lastReadRef) < 0
197
+ ? userProgress.lastReadRef
198
+ : msgRef; // max(read, loc)
199
+ // newly announced delivered is older than or equal what is already registered
200
+ if (compareRefsAsc(newDelivered, userProgress.lastDeliveredRef) <= 0) return;
201
+
202
+ removeByOldKey(
203
+ this.deliveredSorted,
204
+ userProgress,
205
+ userProgress.lastDeliveredRef,
206
+ (x) => x.lastDeliveredRef,
207
+ );
208
+ userProgress.lastDeliveredRef = newDelivered;
209
+ insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
210
+ }
211
+
212
+ /** message.read — user read up to and including messageId. */
213
+ onMessageRead({
214
+ user,
215
+ readAt,
216
+ lastReadMessageId,
217
+ }: {
218
+ user: UserResponse;
219
+ readAt: string;
220
+ lastReadMessageId?: string;
221
+ }) {
222
+ const timestampMs = new Date(readAt).getTime();
223
+ const msgRef = lastReadMessageId
224
+ ? { timestampMs, msgId: lastReadMessageId }
225
+ : this.locateMessage(timestampMs);
226
+ if (!msgRef) return;
227
+ const userProgress = this.ensureUser(user);
228
+ // newly announced read message is older than or equal the already recorded last read message
229
+ if (compareRefsAsc(msgRef, userProgress.lastReadRef) <= 0) return;
230
+
231
+ // move in readSorted
232
+ removeByOldKey(
233
+ this.readSorted,
234
+ userProgress,
235
+ userProgress.lastReadRef,
236
+ (x) => x.lastReadRef,
237
+ );
238
+ userProgress.lastReadRef = msgRef;
239
+ insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef);
240
+
241
+ // keep delivered >= read
242
+ if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) {
243
+ removeByOldKey(
244
+ this.deliveredSorted,
245
+ userProgress,
246
+ userProgress.lastDeliveredRef,
247
+ (x) => x.lastDeliveredRef,
248
+ );
249
+ userProgress.lastDeliveredRef = userProgress.lastReadRef;
250
+ insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
251
+ }
252
+ }
253
+
254
+ /** notification.mark_unread — user marked messages unread starting at `first_unread_message_id`.
255
+ * Sets lastReadRef to the event’s last_read_* values. Delivery never moves backward.
256
+ * The event is sent only to the user that triggered the action (own user), so we will never adjust read ref
257
+ * for other users - we will not see changes in the UI for other users. However, this implementation does not
258
+ * take into consideration this fact and is ready to handle the mark-unread event for any user.
259
+ */
260
+ onNotificationMarkUnread({
261
+ user,
262
+ lastReadAt,
263
+ lastReadMessageId,
264
+ }: {
265
+ user: UserResponse;
266
+ lastReadAt?: string;
267
+ lastReadMessageId?: string;
268
+ }) {
269
+ const userProgress = this.ensureUser(user);
270
+
271
+ const newReadRef: MsgRef = lastReadAt
272
+ ? { timestampMs: new Date(lastReadAt).getTime(), msgId: lastReadMessageId ?? '' }
273
+ : { ...MIN_REF };
274
+
275
+ // If no change, exit early.
276
+ if (
277
+ compareRefsAsc(newReadRef, userProgress.lastReadRef) === 0 &&
278
+ newReadRef.msgId === userProgress.lastReadRef.msgId
279
+ ) {
280
+ return;
281
+ }
282
+
283
+ removeByOldKey(
284
+ this.readSorted,
285
+ userProgress,
286
+ userProgress.lastReadRef,
287
+ (x) => x.lastReadRef,
288
+ );
289
+ userProgress.lastReadRef = newReadRef;
290
+ insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef);
291
+
292
+ // Maintain invariant delivered >= read.
293
+ if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) {
294
+ removeByOldKey(
295
+ this.deliveredSorted,
296
+ userProgress,
297
+ userProgress.lastDeliveredRef,
298
+ (x) => x.lastDeliveredRef,
299
+ );
300
+ userProgress.lastDeliveredRef = userProgress.lastReadRef;
301
+ insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
302
+ }
303
+ }
304
+
305
+ /** All users who READ this message. */
306
+ readersForMessage(msgRef: MsgRef): UserResponse[] {
307
+ const index = findIndex(this.readSorted, msgRef, ({ lastReadRef }) => lastReadRef);
308
+ return this.readSorted.slice(index).map((x) => x.user);
309
+ }
310
+
311
+ /** All users who have it DELIVERED (includes readers). */
312
+ deliveredForMessage(msgRef: MsgRef): UserResponse[] {
313
+ const pos = findIndex(
314
+ this.deliveredSorted,
315
+ msgRef,
316
+ ({ lastDeliveredRef }) => lastDeliveredRef,
317
+ );
318
+ return this.deliveredSorted.slice(pos).map((x) => x.user);
319
+ }
320
+
321
+ /** Users who delivered but have NOT read. */
322
+ deliveredNotReadForMessage(msgRef: MsgRef): UserResponse[] {
323
+ const pos = findIndex(
324
+ this.deliveredSorted,
325
+ msgRef,
326
+ ({ lastDeliveredRef }) => lastDeliveredRef,
327
+ );
328
+ const usersDeliveredNotRead: UserResponse[] = [];
329
+ for (let i = pos; i < this.deliveredSorted.length; i++) {
330
+ const userProgress = this.deliveredSorted[i];
331
+ if (compareRefsAsc(userProgress.lastReadRef, msgRef) < 0)
332
+ usersDeliveredNotRead.push(userProgress.user);
333
+ }
334
+ return usersDeliveredNotRead;
335
+ }
336
+
337
+ /** Users for whom `msgRef` is their *last read* (exact match). */
338
+ usersWhoseLastReadIs(msgRef: MsgRef): UserResponse[] {
339
+ if (!msgRef.msgId) return [];
340
+ const start = findIndex(this.readSorted, msgRef, (x) => x.lastReadRef);
341
+ const end = findUpperIndex(this.readSorted, msgRef, (x) => x.lastReadRef);
342
+ const users: UserResponse[] = [];
343
+ for (let i = start; i < end; i++) {
344
+ const up = this.readSorted[i];
345
+ if (up.lastReadRef.msgId === msgRef.msgId) users.push(up.user);
346
+ }
347
+ return users;
348
+ }
349
+
350
+ /** Users for whom `msgRef` is their *last delivered* (exact match). */
351
+ usersWhoseLastDeliveredIs(msgRef: MsgRef): UserResponse[] {
352
+ if (!msgRef.msgId) return [];
353
+ const start = findIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef);
354
+ const end = findUpperIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef);
355
+ const users: UserResponse[] = [];
356
+ for (let i = start; i < end; i++) {
357
+ const up = this.deliveredSorted[i];
358
+ if (up.lastDeliveredRef.msgId === msgRef.msgId) users.push(up.user);
359
+ }
360
+ return users;
361
+ }
362
+
363
+ // ---- queries: per-user status ----
364
+
365
+ hasUserRead(msgRef: MsgRef, userId: string): boolean {
366
+ const up = this.byUser.get(userId);
367
+ return !!up && compareRefsAsc(up.lastReadRef, msgRef) >= 0;
368
+ }
369
+
370
+ hasUserDelivered(msgRef: MsgRef, userId: string): boolean {
371
+ const up = this.byUser.get(userId);
372
+ return !!up && compareRefsAsc(up.lastDeliveredRef, msgRef) >= 0;
373
+ }
374
+
375
+ getUserProgress(userId: string): UserProgress | null {
376
+ const userProgress = this.byUser.get(userId);
377
+ if (!userProgress) return null;
378
+ return userProgress;
379
+ }
380
+
381
+ groupUsersByLastReadMessage(): Record<MessageId, UserResponse[]> {
382
+ return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>(
383
+ (acc, userProgress) => {
384
+ const msgId = userProgress.lastReadRef.msgId;
385
+ if (!msgId) return acc;
386
+ if (!acc[msgId]) acc[msgId] = [];
387
+ acc[msgId].push(userProgress.user);
388
+ return acc;
389
+ },
390
+ {},
391
+ );
392
+ }
393
+
394
+ groupUsersByLastDeliveredMessage(): Record<MessageId, UserResponse[]> {
395
+ return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>(
396
+ (acc, userProgress) => {
397
+ const msgId = userProgress.lastDeliveredRef.msgId;
398
+ if (!msgId) return acc;
399
+ if (!acc[msgId]) acc[msgId] = [];
400
+ acc[msgId].push(userProgress.user);
401
+ return acc;
402
+ },
403
+ {},
404
+ );
405
+ }
406
+
407
+ private ensureUser(user: UserResponse): UserProgress {
408
+ let up = this.byUser.get(user.id);
409
+ if (!up) {
410
+ up = { user, lastReadRef: MIN_REF, lastDeliveredRef: MIN_REF };
411
+ this.byUser.set(user.id, up);
412
+ insertByKey(this.readSorted, up, (x) => x.lastReadRef);
413
+ insertByKey(this.deliveredSorted, up, (x) => x.lastDeliveredRef);
414
+ }
415
+ return up;
416
+ }
417
+ }
@@ -0,0 +1,2 @@
1
+ export * from './MessageDeliveryReporter';
2
+ export * from './MessageReceiptsTracker';
package/src/thread.ts CHANGED
@@ -534,7 +534,7 @@ export class Thread extends WithSubscriptions {
534
534
  return null;
535
535
  }
536
536
 
537
- return await this.channel.markRead({ thread_id: this.id });
537
+ return await this.client.messageDeliveryReporter.markRead(this);
538
538
  };
539
539
 
540
540
  private throttledMarkAsRead = throttle(