stream-chat 8.38.0 → 8.40.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.
Files changed (42) hide show
  1. package/README.md +15 -2
  2. package/dist/browser.es.js +1667 -372
  3. package/dist/browser.es.js.map +1 -1
  4. package/dist/browser.full-bundle.min.js +1 -1
  5. package/dist/browser.full-bundle.min.js.map +1 -1
  6. package/dist/browser.js +1668 -371
  7. package/dist/browser.js.map +1 -1
  8. package/dist/index.es.js +1667 -372
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/index.js +1668 -371
  11. package/dist/index.js.map +1 -1
  12. package/dist/types/channel.d.ts +6 -8
  13. package/dist/types/channel.d.ts.map +1 -1
  14. package/dist/types/channel_state.d.ts +14 -22
  15. package/dist/types/channel_state.d.ts.map +1 -1
  16. package/dist/types/client.d.ts +3 -1
  17. package/dist/types/client.d.ts.map +1 -1
  18. package/dist/types/constants.d.ts +7 -0
  19. package/dist/types/constants.d.ts.map +1 -0
  20. package/dist/types/index.d.ts +2 -0
  21. package/dist/types/index.d.ts.map +1 -1
  22. package/dist/types/store.d.ts +14 -0
  23. package/dist/types/store.d.ts.map +1 -0
  24. package/dist/types/thread.d.ts +93 -29
  25. package/dist/types/thread.d.ts.map +1 -1
  26. package/dist/types/thread_manager.d.ts +51 -0
  27. package/dist/types/thread_manager.d.ts.map +1 -0
  28. package/dist/types/types.d.ts +35 -18
  29. package/dist/types/types.d.ts.map +1 -1
  30. package/dist/types/utils.d.ts +48 -7
  31. package/dist/types/utils.d.ts.map +1 -1
  32. package/package.json +7 -6
  33. package/src/channel.ts +28 -13
  34. package/src/channel_state.ts +30 -27
  35. package/src/client.ts +182 -104
  36. package/src/constants.ts +4 -0
  37. package/src/index.ts +2 -0
  38. package/src/store.ts +57 -0
  39. package/src/thread.ts +470 -107
  40. package/src/thread_manager.ts +297 -0
  41. package/src/types.ts +34 -19
  42. package/src/utils.ts +362 -43
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE = 25;
2
+ export const DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE = 100;
3
+
4
+ export const DEFAULT_MESSAGE_SET_PAGINATION = { hasNext: true, hasPrev: true };
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from './client_state';
4
4
  export * from './channel';
5
5
  export * from './channel_state';
6
6
  export * from './thread';
7
+ export * from './thread_manager';
7
8
  export * from './connection';
8
9
  export * from './events';
9
10
  export * from './moderation';
@@ -15,3 +16,4 @@ export * from './types';
15
16
  export * from './segment';
16
17
  export * from './campaign';
17
18
  export { isOwnUser, chatCodes, logChatPromiseExecution, formatMessage } from './utils';
19
+ export * from './store';
package/src/store.ts ADDED
@@ -0,0 +1,57 @@
1
+ export type Patch<T> = (value: T) => T;
2
+ export type Handler<T> = (nextValue: T, previousValue: T | undefined) => void;
3
+ export type Unsubscribe = () => void;
4
+
5
+ function isPatch<T>(value: T | Patch<T>): value is Patch<T> {
6
+ return typeof value === 'function';
7
+ }
8
+
9
+ export class StateStore<T extends Record<string, unknown>> {
10
+ private handlerSet = new Set<Handler<T>>();
11
+
12
+ constructor(private value: T) {}
13
+
14
+ public next = (newValueOrPatch: T | Patch<T>): void => {
15
+ // newValue (or patch output) should never be mutated previous value
16
+ const newValue = isPatch(newValueOrPatch) ? newValueOrPatch(this.value) : newValueOrPatch;
17
+
18
+ // do not notify subscribers if the value hasn't changed
19
+ if (newValue === this.value) return;
20
+
21
+ const oldValue = this.value;
22
+ this.value = newValue;
23
+
24
+ this.handlerSet.forEach((handler) => handler(this.value, oldValue));
25
+ };
26
+
27
+ public partialNext = (partial: Partial<T>): void => this.next((current) => ({ ...current, ...partial }));
28
+
29
+ public getLatestValue = (): T => this.value;
30
+
31
+ public subscribe = (handler: Handler<T>): Unsubscribe => {
32
+ handler(this.value, undefined);
33
+ this.handlerSet.add(handler);
34
+ return () => {
35
+ this.handlerSet.delete(handler);
36
+ };
37
+ };
38
+
39
+ public subscribeWithSelector = <O extends readonly unknown[]>(selector: (nextValue: T) => O, handler: Handler<O>) => {
40
+ // begin with undefined to reduce amount of selector calls
41
+ let selectedValues: O | undefined;
42
+
43
+ const wrappedHandler: Handler<T> = (nextValue) => {
44
+ const newlySelectedValues = selector(nextValue);
45
+ const hasUpdatedValues = selectedValues?.some((value, index) => value !== newlySelectedValues[index]) ?? true;
46
+
47
+ if (!hasUpdatedValues) return;
48
+
49
+ const oldSelectedValues = selectedValues;
50
+ selectedValues = newlySelectedValues;
51
+
52
+ handler(newlySelectedValues, oldSelectedValues);
53
+ };
54
+
55
+ return this.subscribe(wrappedHandler);
56
+ };
57
+ }
package/src/thread.ts CHANGED
@@ -1,142 +1,505 @@
1
- import { StreamChat } from './client';
2
- import {
1
+ import type { Channel } from './channel';
2
+ import type { StreamChat } from './client';
3
+ import { StateStore } from './store';
4
+ import type {
5
+ AscDesc,
3
6
  DefaultGenerics,
4
7
  ExtendableGenerics,
8
+ FormatMessageResponse,
9
+ MessagePaginationOptions,
5
10
  MessageResponse,
11
+ ReadResponse,
6
12
  ThreadResponse,
7
- ChannelResponse,
8
- FormatMessageResponse,
9
- ReactionResponse,
10
13
  UserResponse,
11
14
  } from './types';
12
- import { addToMessageList, formatMessage } from './utils';
15
+ import { addToMessageList, findIndexInSortedArray, formatMessage, throttle } from './utils';
16
+
17
+ type QueryRepliesOptions<SCG extends ExtendableGenerics> = {
18
+ sort?: { created_at: AscDesc }[];
19
+ } & MessagePaginationOptions & { user?: UserResponse<SCG>; user_id?: string };
20
+
21
+ export type ThreadState<SCG extends ExtendableGenerics = DefaultGenerics> = {
22
+ /**
23
+ * Determines if the thread is currently opened and on-screen. When the thread is active,
24
+ * all new messages are immediately marked as read.
25
+ */
26
+ active: boolean;
27
+ channel: Channel<SCG>;
28
+ createdAt: Date;
29
+ deletedAt: Date | null;
30
+ isLoading: boolean;
31
+ isStateStale: boolean;
32
+ pagination: ThreadRepliesPagination;
33
+ /**
34
+ * Thread is identified by and has a one-to-one relation with its parent message.
35
+ * We use parent message id as a thread id.
36
+ */
37
+ parentMessage: FormatMessageResponse<SCG>;
38
+ participants: ThreadResponse<SCG>['thread_participants'];
39
+ read: ThreadReadState;
40
+ replies: Array<FormatMessageResponse<SCG>>;
41
+ replyCount: number;
42
+ updatedAt: Date | null;
43
+ };
13
44
 
14
- type ThreadReadStatus<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> = Record<
45
+ export type ThreadRepliesPagination = {
46
+ isLoadingNext: boolean;
47
+ isLoadingPrev: boolean;
48
+ nextCursor: string | null;
49
+ prevCursor: string | null;
50
+ };
51
+
52
+ export type ThreadUserReadState<SCG extends ExtendableGenerics = DefaultGenerics> = {
53
+ lastReadAt: Date;
54
+ unreadMessageCount: number;
55
+ user: UserResponse<SCG>;
56
+ lastReadMessageId?: string;
57
+ };
58
+
59
+ export type ThreadReadState<SCG extends ExtendableGenerics = DefaultGenerics> = Record<
15
60
  string,
16
- {
17
- last_read: Date;
18
- last_read_message_id: string;
19
- unread_messages: number;
20
- user: UserResponse<StreamChatGenerics>;
21
- }
61
+ ThreadUserReadState<SCG> | undefined
22
62
  >;
23
63
 
24
- export class Thread<StreamChatGenerics extends ExtendableGenerics = DefaultGenerics> {
25
- id: string;
26
- latestReplies: FormatMessageResponse<StreamChatGenerics>[] = [];
27
- participants: ThreadResponse['thread_participants'] = [];
28
- message: FormatMessageResponse<StreamChatGenerics>;
29
- channel: ChannelResponse<StreamChatGenerics>;
30
- _channel: ReturnType<StreamChat<StreamChatGenerics>['channel']>;
31
- replyCount = 0;
32
- _client: StreamChat<StreamChatGenerics>;
33
- read: ThreadReadStatus<StreamChatGenerics> = {};
34
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
- data: Record<string, any> = {};
36
-
37
- constructor(client: StreamChat<StreamChatGenerics>, t: ThreadResponse<StreamChatGenerics>) {
38
- const {
39
- parent_message_id,
40
- parent_message,
41
- latest_replies,
42
- thread_participants,
43
- reply_count,
44
- channel,
45
- read,
46
- ...data
47
- } = t;
48
-
49
- this.id = parent_message_id;
50
- this.message = formatMessage(parent_message);
51
- this.latestReplies = latest_replies.map(formatMessage);
52
- this.participants = thread_participants;
53
- this.replyCount = reply_count;
54
- this.channel = channel;
55
- this._channel = client.channel(t.channel.type, t.channel.id);
56
- this._client = client;
57
- if (read) {
58
- for (const r of read) {
59
- this.read[r.user.id] = {
60
- ...r,
61
- last_read: new Date(r.last_read),
62
- };
63
- }
64
- }
65
- this.data = data;
66
- }
64
+ const DEFAULT_PAGE_LIMIT = 50;
65
+ const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }];
66
+ const MARK_AS_READ_THROTTLE_TIMEOUT = 1000;
67
+
68
+ export class Thread<SCG extends ExtendableGenerics = DefaultGenerics> {
69
+ public readonly state: StateStore<ThreadState<SCG>>;
70
+ public readonly id: string;
71
+
72
+ private client: StreamChat<SCG>;
73
+ private unsubscribeFunctions: Set<() => void> = new Set();
74
+ private failedRepliesMap: Map<string, FormatMessageResponse<SCG>> = new Map();
67
75
 
68
- getClient(): StreamChat<StreamChatGenerics> {
69
- return this._client;
76
+ constructor({ client, threadData }: { client: StreamChat<SCG>; threadData: ThreadResponse<SCG> }) {
77
+ this.state = new StateStore<ThreadState<SCG>>({
78
+ active: false,
79
+ channel: client.channel(threadData.channel.type, threadData.channel.id),
80
+ createdAt: new Date(threadData.created_at),
81
+ deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
82
+ isLoading: false,
83
+ isStateStale: false,
84
+ pagination: repliesPaginationFromInitialThread(threadData),
85
+ parentMessage: formatMessage(threadData.parent_message),
86
+ participants: threadData.thread_participants,
87
+ read: formatReadState(threadData.read ?? []),
88
+ replies: threadData.latest_replies.map(formatMessage),
89
+ replyCount: threadData.reply_count ?? 0,
90
+ updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
91
+ });
92
+
93
+ this.id = threadData.parent_message_id;
94
+ this.client = client;
70
95
  }
71
96
 
72
- /**
73
- * addReply - Adds or updates a latestReplies to the thread
74
- *
75
- * @param {MessageResponse<StreamChatGenerics>} message reply message to be added.
76
- */
77
- addReply(message: MessageResponse<StreamChatGenerics>) {
78
- if (message.parent_id !== this.message.id) {
79
- throw new Error('Message does not belong to this thread');
80
- }
97
+ get channel() {
98
+ return this.state.getLatestValue().channel;
99
+ }
81
100
 
82
- this.latestReplies = addToMessageList(this.latestReplies, formatMessage(message), true);
101
+ get hasStaleState() {
102
+ return this.state.getLatestValue().isStateStale;
83
103
  }
84
104
 
85
- updateReply(message: MessageResponse<StreamChatGenerics>) {
86
- this.latestReplies = this.latestReplies.map((m) => {
87
- if (m.id === message.id) {
88
- return formatMessage(message);
89
- }
90
- return m;
91
- });
105
+ get ownUnreadCount() {
106
+ return ownUnreadCountSelector(this.client.userID)(this.state.getLatestValue());
92
107
  }
93
108
 
94
- updateMessageOrReplyIfExists(message: MessageResponse<StreamChatGenerics>) {
95
- if (!message.parent_id && message.id !== this.message.id) {
109
+ public activate = () => {
110
+ this.state.partialNext({ active: true });
111
+ };
112
+
113
+ public deactivate = () => {
114
+ this.state.partialNext({ active: false });
115
+ };
116
+
117
+ public reload = async () => {
118
+ if (this.state.getLatestValue().isLoading) {
96
119
  return;
97
120
  }
98
121
 
99
- if (message.parent_id && message.parent_id !== this.message.id) {
100
- return;
122
+ this.state.partialNext({ isLoading: true });
123
+
124
+ try {
125
+ const thread = await this.client.getThread(this.id, { watch: true });
126
+ this.hydrateState(thread);
127
+ } finally {
128
+ this.state.partialNext({ isLoading: false });
101
129
  }
130
+ };
102
131
 
103
- if (message.parent_id && message.parent_id === this.message.id) {
104
- this.updateReply(message);
132
+ public hydrateState = (thread: Thread<SCG>) => {
133
+ if (thread === this) {
134
+ // skip if the instances are the same
105
135
  return;
106
136
  }
107
137
 
108
- if (!message.parent_id && message.id === this.message.id) {
109
- this.message = formatMessage(message);
138
+ if (thread.id !== this.id) {
139
+ throw new Error("Cannot hydrate thread state with using thread's state");
110
140
  }
111
- }
112
141
 
113
- addReaction(
114
- reaction: ReactionResponse<StreamChatGenerics>,
115
- message?: MessageResponse<StreamChatGenerics>,
116
- enforce_unique?: boolean,
117
- ) {
118
- if (!message) return;
119
-
120
- this.latestReplies = this.latestReplies.map((m) => {
121
- if (m.id === message.id) {
122
- return formatMessage(
123
- this._channel.state.addReaction(reaction, message, enforce_unique) as MessageResponse<StreamChatGenerics>,
124
- );
142
+ const {
143
+ read,
144
+ replyCount,
145
+ replies,
146
+ parentMessage,
147
+ participants,
148
+ createdAt,
149
+ deletedAt,
150
+ updatedAt,
151
+ } = thread.state.getLatestValue();
152
+
153
+ // Preserve pending replies and append them to the updated list of replies
154
+ const pendingReplies = Array.from(this.failedRepliesMap.values());
155
+
156
+ this.state.partialNext({
157
+ read,
158
+ replyCount,
159
+ replies: pendingReplies.length ? replies.concat(pendingReplies) : replies,
160
+ parentMessage,
161
+ participants,
162
+ createdAt,
163
+ deletedAt,
164
+ updatedAt,
165
+ isStateStale: false,
166
+ });
167
+ };
168
+
169
+ public registerSubscriptions = () => {
170
+ if (this.unsubscribeFunctions.size) {
171
+ // Thread is already listening for events and changes
172
+ return;
173
+ }
174
+
175
+ this.unsubscribeFunctions.add(this.subscribeMarkActiveThreadRead());
176
+ this.unsubscribeFunctions.add(this.subscribeReloadActiveStaleThread());
177
+ this.unsubscribeFunctions.add(this.subscribeMarkThreadStale());
178
+ this.unsubscribeFunctions.add(this.subscribeNewReplies());
179
+ this.unsubscribeFunctions.add(this.subscribeRepliesRead());
180
+ this.unsubscribeFunctions.add(this.subscribeReplyDeleted());
181
+ this.unsubscribeFunctions.add(this.subscribeMessageUpdated());
182
+ };
183
+
184
+ private subscribeMarkActiveThreadRead = () => {
185
+ return this.state.subscribeWithSelector(
186
+ (nextValue) => [nextValue.active, ownUnreadCountSelector(this.client.userID)(nextValue)],
187
+ ([active, unreadMessageCount]) => {
188
+ if (!active || !unreadMessageCount) return;
189
+ this.throttledMarkAsRead();
190
+ },
191
+ );
192
+ };
193
+
194
+ private subscribeReloadActiveStaleThread = () =>
195
+ this.state.subscribeWithSelector(
196
+ (nextValue) => [nextValue.active, nextValue.isStateStale],
197
+ ([active, isStateStale]) => {
198
+ if (active && isStateStale) {
199
+ this.reload();
200
+ }
201
+ },
202
+ );
203
+
204
+ private subscribeMarkThreadStale = () =>
205
+ this.client.on('user.watching.stop', (event) => {
206
+ const { channel } = this.state.getLatestValue();
207
+
208
+ if (!this.client.userID || this.client.userID !== event.user?.id || event.channel?.cid !== channel.cid) {
209
+ return;
210
+ }
211
+
212
+ this.state.partialNext({ isStateStale: true });
213
+ }).unsubscribe;
214
+
215
+ private subscribeNewReplies = () =>
216
+ this.client.on('message.new', (event) => {
217
+ if (!this.client.userID || event.message?.parent_id !== this.id) {
218
+ return;
219
+ }
220
+
221
+ const isOwnMessage = event.message.user?.id === this.client.userID;
222
+ const { active, read } = this.state.getLatestValue();
223
+
224
+ this.upsertReplyLocally({
225
+ message: event.message,
226
+ // Message from current user could have been added optimistically,
227
+ // so the actual timestamp might differ in the event
228
+ timestampChanged: isOwnMessage,
229
+ });
230
+
231
+ if (active) {
232
+ this.throttledMarkAsRead();
233
+ }
234
+
235
+ const nextRead: ThreadReadState = {};
236
+
237
+ for (const userId of Object.keys(read)) {
238
+ const userRead = read[userId];
239
+
240
+ if (userRead) {
241
+ let nextUserRead: ThreadUserReadState = userRead;
242
+
243
+ if (userId === event.user?.id) {
244
+ // The user who just sent a message to the thread has no unread messages
245
+ // in that thread
246
+ nextUserRead = {
247
+ ...nextUserRead,
248
+ lastReadAt: event.created_at ? new Date(event.created_at) : new Date(),
249
+ user: event.user,
250
+ unreadMessageCount: 0,
251
+ };
252
+ } else if (active && userId === this.client.userID) {
253
+ // Do not increment unread count for the current user in an active thread
254
+ } else {
255
+ // Increment unread count for all users except the author of the new message
256
+ nextUserRead = {
257
+ ...nextUserRead,
258
+ unreadMessageCount: userRead.unreadMessageCount + 1,
259
+ };
260
+ }
261
+
262
+ nextRead[userId] = nextUserRead;
263
+ }
264
+ }
265
+
266
+ this.state.partialNext({ read: nextRead });
267
+ }).unsubscribe;
268
+
269
+ private subscribeRepliesRead = () =>
270
+ this.client.on('message.read', (event) => {
271
+ if (!event.user || !event.created_at || !event.thread) return;
272
+ if (event.thread.parent_message_id !== this.id) return;
273
+
274
+ const userId = event.user.id;
275
+ const createdAt = event.created_at;
276
+ const user = event.user;
277
+
278
+ this.state.next((current) => ({
279
+ ...current,
280
+ read: {
281
+ ...current.read,
282
+ [userId]: {
283
+ lastReadAt: new Date(createdAt),
284
+ user,
285
+ lastReadMessageId: event.last_read_message_id,
286
+ unreadMessageCount: 0,
287
+ },
288
+ },
289
+ }));
290
+ }).unsubscribe;
291
+
292
+ private subscribeReplyDeleted = () =>
293
+ this.client.on('message.deleted', (event) => {
294
+ if (event.message?.parent_id === this.id && event.hard_delete) {
295
+ return this.deleteReplyLocally({ message: event.message });
125
296
  }
126
- return m;
297
+ }).unsubscribe;
298
+
299
+ private subscribeMessageUpdated = () => {
300
+ const unsubscribeFunctions = ['message.updated', 'reaction.new', 'reaction.deleted'].map(
301
+ (eventType) =>
302
+ this.client.on(eventType, (event) => {
303
+ if (event.message) {
304
+ this.updateParentMessageOrReplyLocally(event.message);
305
+ }
306
+ }).unsubscribe,
307
+ );
308
+
309
+ return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
310
+ };
311
+
312
+ public unregisterSubscriptions = () => {
313
+ this.unsubscribeFunctions.forEach((cleanupFunction) => cleanupFunction());
314
+ this.unsubscribeFunctions.clear();
315
+ };
316
+
317
+ public deleteReplyLocally = ({ message }: { message: MessageResponse<SCG> }) => {
318
+ const { replies } = this.state.getLatestValue();
319
+
320
+ const index = findIndexInSortedArray({
321
+ needle: formatMessage(message),
322
+ sortedArray: replies,
323
+ sortDirection: 'ascending',
324
+ selectValueToCompare: (reply) => reply.created_at.getTime(),
127
325
  });
128
- }
129
326
 
130
- removeReaction(reaction: ReactionResponse<StreamChatGenerics>, message?: MessageResponse<StreamChatGenerics>) {
131
- if (!message) return;
327
+ const actualIndex =
328
+ replies[index]?.id === message.id ? index : replies[index - 1]?.id === message.id ? index - 1 : null;
329
+
330
+ if (actualIndex === null) {
331
+ return;
332
+ }
333
+
334
+ const updatedReplies = [...replies];
335
+ updatedReplies.splice(actualIndex, 1);
336
+
337
+ this.state.partialNext({
338
+ replies: updatedReplies,
339
+ });
340
+ };
341
+
342
+ public upsertReplyLocally = ({
343
+ message,
344
+ timestampChanged = false,
345
+ }: {
346
+ message: MessageResponse<SCG>;
347
+ timestampChanged?: boolean;
348
+ }) => {
349
+ if (message.parent_id !== this.id) {
350
+ throw new Error('Reply does not belong to this thread');
351
+ }
352
+
353
+ const formattedMessage = formatMessage(message);
354
+
355
+ if (message.status === 'failed') {
356
+ // store failed reply so that it's not lost when reloading or hydrating
357
+ this.failedRepliesMap.set(formattedMessage.id, formattedMessage);
358
+ } else if (this.failedRepliesMap.has(message.id)) {
359
+ this.failedRepliesMap.delete(message.id);
360
+ }
361
+
362
+ this.state.next((current) => ({
363
+ ...current,
364
+ replies: addToMessageList(current.replies, formattedMessage, timestampChanged),
365
+ }));
366
+ };
367
+
368
+ public updateParentMessageLocally = (message: MessageResponse<SCG>) => {
369
+ if (message.id !== this.id) {
370
+ throw new Error('Message does not belong to this thread');
371
+ }
372
+
373
+ this.state.next((current) => {
374
+ const formattedMessage = formatMessage(message);
375
+
376
+ const newData: typeof current = {
377
+ ...current,
378
+ deletedAt: formattedMessage.deleted_at,
379
+ parentMessage: formattedMessage,
380
+ replyCount: message.reply_count ?? current.replyCount,
381
+ };
132
382
 
133
- this.latestReplies = this.latestReplies.map((m) => {
134
- if (m.id === message.id) {
135
- return formatMessage(
136
- this._channel.state.removeReaction(reaction, message) as MessageResponse<StreamChatGenerics>,
137
- );
383
+ // update channel on channelData change (unlikely but handled anyway)
384
+ if (message.channel) {
385
+ newData['channel'] = this.client.channel(message.channel.type, message.channel.id, message.channel);
138
386
  }
139
- return m;
387
+
388
+ return newData;
140
389
  });
141
- }
390
+ };
391
+
392
+ public updateParentMessageOrReplyLocally = (message: MessageResponse<SCG>) => {
393
+ if (message.parent_id === this.id) {
394
+ this.upsertReplyLocally({ message });
395
+ }
396
+
397
+ if (!message.parent_id && message.id === this.id) {
398
+ this.updateParentMessageLocally(message);
399
+ }
400
+ };
401
+
402
+ public markAsRead = async ({ force = false }: { force?: boolean } = {}) => {
403
+ if (this.ownUnreadCount === 0 && !force) {
404
+ return null;
405
+ }
406
+
407
+ return await this.channel.markRead({ thread_id: this.id });
408
+ };
409
+
410
+ private throttledMarkAsRead = throttle(() => this.markAsRead(), MARK_AS_READ_THROTTLE_TIMEOUT, { trailing: true });
411
+
412
+ public queryReplies = ({
413
+ limit = DEFAULT_PAGE_LIMIT,
414
+ sort = DEFAULT_SORT,
415
+ ...otherOptions
416
+ }: QueryRepliesOptions<SCG> = {}) => {
417
+ return this.channel.getReplies(this.id, { limit, ...otherOptions }, sort);
418
+ };
419
+
420
+ public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => {
421
+ return this.loadPage(limit);
422
+ };
423
+
424
+ public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) => {
425
+ return this.loadPage(-limit);
426
+ };
427
+
428
+ private loadPage = async (count: number) => {
429
+ const { pagination } = this.state.getLatestValue();
430
+ const [loadingKey, cursorKey, insertionMethodKey] =
431
+ count > 0
432
+ ? (['isLoadingNext', 'nextCursor', 'push'] as const)
433
+ : (['isLoadingPrev', 'prevCursor', 'unshift'] as const);
434
+
435
+ if (pagination[loadingKey] || pagination[cursorKey] === null) return;
436
+
437
+ const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: pagination[cursorKey] };
438
+ const limit = Math.abs(count);
439
+
440
+ this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } });
441
+
442
+ try {
443
+ const data = await this.queryReplies({ ...queryOptions, limit });
444
+ const replies = data.messages.map(formatMessage);
445
+ const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null;
446
+
447
+ this.state.next((current) => {
448
+ let nextReplies = current.replies;
449
+
450
+ // prevent re-creating array if there's nothing to add to the current one
451
+ if (replies.length > 0) {
452
+ nextReplies = [...current.replies];
453
+ nextReplies[insertionMethodKey](...replies);
454
+ }
455
+
456
+ return {
457
+ ...current,
458
+ replies: nextReplies,
459
+ pagination: {
460
+ ...current.pagination,
461
+ [cursorKey]: data.messages.length < limit ? null : maybeNextCursor,
462
+ [loadingKey]: false,
463
+ },
464
+ };
465
+ });
466
+ } catch (error) {
467
+ this.client.logger('error', (error as Error).message);
468
+ this.state.next((current) => ({
469
+ ...current,
470
+ pagination: {
471
+ ...current.pagination,
472
+ [loadingKey]: false,
473
+ },
474
+ }));
475
+ }
476
+ };
142
477
  }
478
+
479
+ const formatReadState = (read: ReadResponse[]): ThreadReadState =>
480
+ read.reduce<ThreadReadState>((state, userRead) => {
481
+ state[userRead.user.id] = {
482
+ user: userRead.user,
483
+ lastReadMessageId: userRead.last_read_message_id,
484
+ unreadMessageCount: userRead.unread_messages ?? 0,
485
+ lastReadAt: new Date(userRead.last_read),
486
+ };
487
+ return state;
488
+ }, {});
489
+
490
+ const repliesPaginationFromInitialThread = (thread: ThreadResponse): ThreadRepliesPagination => {
491
+ const latestRepliesContainsAllReplies = thread.latest_replies.length === thread.reply_count;
492
+
493
+ return {
494
+ nextCursor: null,
495
+ prevCursor: latestRepliesContainsAllReplies ? null : thread.latest_replies.at(0)?.id ?? null,
496
+ isLoadingNext: false,
497
+ isLoadingPrev: false,
498
+ };
499
+ };
500
+
501
+ const ownUnreadCountSelector = (currentUserId: string | undefined) => <
502
+ SCG extends ExtendableGenerics = DefaultGenerics
503
+ >(
504
+ state: ThreadState<SCG>,
505
+ ) => (currentUserId && state.read[currentUserId]?.unreadMessageCount) || 0;