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.
- package/README.md +15 -2
- package/dist/browser.es.js +1667 -372
- package/dist/browser.es.js.map +1 -1
- package/dist/browser.full-bundle.min.js +1 -1
- package/dist/browser.full-bundle.min.js.map +1 -1
- package/dist/browser.js +1668 -371
- package/dist/browser.js.map +1 -1
- package/dist/index.es.js +1667 -372
- package/dist/index.es.js.map +1 -1
- package/dist/index.js +1668 -371
- package/dist/index.js.map +1 -1
- package/dist/types/channel.d.ts +6 -8
- package/dist/types/channel.d.ts.map +1 -1
- package/dist/types/channel_state.d.ts +14 -22
- package/dist/types/channel_state.d.ts.map +1 -1
- package/dist/types/client.d.ts +3 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/constants.d.ts +7 -0
- package/dist/types/constants.d.ts.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/store.d.ts +14 -0
- package/dist/types/store.d.ts.map +1 -0
- package/dist/types/thread.d.ts +93 -29
- package/dist/types/thread.d.ts.map +1 -1
- package/dist/types/thread_manager.d.ts +51 -0
- package/dist/types/thread_manager.d.ts.map +1 -0
- package/dist/types/types.d.ts +35 -18
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/utils.d.ts +48 -7
- package/dist/types/utils.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/channel.ts +28 -13
- package/src/channel_state.ts +30 -27
- package/src/client.ts +182 -104
- package/src/constants.ts +4 -0
- package/src/index.ts +2 -0
- package/src/store.ts +57 -0
- package/src/thread.ts +470 -107
- package/src/thread_manager.ts +297 -0
- package/src/types.ts +34 -19
- package/src/utils.ts +362 -43
package/src/constants.ts
ADDED
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 {
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
get hasStaleState() {
|
|
102
|
+
return this.state.getLatestValue().isStateStale;
|
|
83
103
|
}
|
|
84
104
|
|
|
85
|
-
|
|
86
|
-
this.
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
-
|
|
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 (
|
|
109
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
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
|
-
|
|
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;
|