stream-chat 9.21.0 → 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,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(
package/src/types.ts CHANGED
@@ -713,6 +713,7 @@ export type MessageResponseBase = MessageBase & {
713
713
  status?: string;
714
714
  thread_participants?: UserResponse[];
715
715
  updated_at?: string;
716
+ deleted_for_me?: boolean;
716
717
  };
717
718
 
718
719
  export type ReactionGroupResponse = {
@@ -968,7 +969,7 @@ export type BanUserOptions = UnBanUserOptions & {
968
969
  ip_ban?: boolean;
969
970
  reason?: string;
970
971
  timeout?: number;
971
- delete_messages?: DeleteMessagesOptions;
972
+ delete_messages?: MessageDeletionStrategy;
972
973
  };
973
974
 
974
975
  export type ChannelOptions = {
@@ -1468,14 +1469,15 @@ export type Event = CustomEventData & {
1468
1469
  ai_state?: AIState;
1469
1470
  channel?: ChannelResponse;
1470
1471
  channel_custom?: CustomChannelData;
1471
- channel_member_count?: number;
1472
1472
  channel_id?: string;
1473
+ channel_member_count?: number;
1473
1474
  channel_type?: string;
1474
1475
  cid?: string;
1475
1476
  clear_history?: boolean;
1476
1477
  connection_id?: string;
1477
1478
  // event creation timestamp, format Date ISO string
1478
1479
  created_at?: string;
1480
+ deleted_for_me?: boolean;
1479
1481
  draft?: DraftResponse;
1480
1482
  // id of the message that was marked as unread - all the following messages are considered unread. (notification.mark_unread)
1481
1483
  first_unread_message_id?: string;
@@ -3602,14 +3604,21 @@ export type CustomCheckFlag = {
3602
3604
  reason?: string;
3603
3605
  };
3604
3606
 
3605
- export type DeleteMessagesOptions = 'soft' | 'hard';
3607
+ export type MessageDeletionStrategy = 'soft' | 'hard';
3608
+ // @deprecated use type MessageDeletionStrategy instead
3609
+ export type DeleteMessagesOptions = MessageDeletionStrategy;
3610
+
3611
+ export type DeleteMessageOptions = {
3612
+ deleteForMe?: boolean;
3613
+ hardDelete?: boolean;
3614
+ };
3606
3615
 
3607
3616
  export type SubmitActionOptions = {
3608
3617
  ban?: {
3609
3618
  channel_ban_only?: boolean;
3610
3619
  reason?: string;
3611
3620
  timeout?: number;
3612
- delete_messages?: DeleteMessagesOptions;
3621
+ delete_messages?: MessageDeletionStrategy;
3613
3622
  };
3614
3623
  delete_message?: {
3615
3624
  hard_delete?: boolean;
package/src/utils.ts CHANGED
@@ -748,7 +748,8 @@ export const debounce = <T extends (...args: any[]) => any>(
748
748
  };
749
749
 
750
750
  // works exactly the same as lodash.throttle
751
- export const throttle = <T extends (...args: unknown[]) => unknown>(
751
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
752
+ export const throttle = <T extends (...args: any[]) => any>(
752
753
  fn: T,
753
754
  timeout = 200,
754
755
  { leading = true, trailing = false }: { leading?: boolean; trailing?: boolean } = {},