@ermis-network/ermis-chat-sdk 1.0.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/src/channel.ts ADDED
@@ -0,0 +1,1704 @@
1
+ import { ChannelState } from './channel_state';
2
+ import { normalizeFileName, isVideoFile, buildAttachmentPayload } from './attachment_utils';
3
+ import type { VoiceRecordingMeta } from './attachment_utils';
4
+ import {
5
+ enrichWithUserInfo,
6
+ ensureMembersUserInfoLoaded,
7
+ getDirectChannelImage,
8
+ getDirectChannelName,
9
+ getUserInfo,
10
+ logChatPromiseExecution,
11
+ randomId,
12
+ } from './utils';
13
+ import { ErmisChat } from './client';
14
+ import {
15
+ APIResponse,
16
+ Attachment,
17
+ ChannelAPIResponse,
18
+ ChannelData,
19
+ ChannelQueryOptions,
20
+ ChannelResponse,
21
+ DefaultGenerics,
22
+ Event,
23
+ EventHandler,
24
+ EventTypes,
25
+ ExtendableGenerics,
26
+ FormatMessageResponse,
27
+ Message,
28
+ MessageResponse,
29
+ MessageSetType,
30
+ ReactionAPIResponse,
31
+ SendMessageAPIResponse,
32
+ UpdateChannelAPIResponse,
33
+ UserResponse,
34
+ QueryChannelAPIResponse,
35
+ AttachmentResponse,
36
+ PollMessage,
37
+ EditMessage,
38
+ ForwardMessage,
39
+ } from './types';
40
+ export class Channel<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
41
+ _client: ErmisChat<ErmisChatGenerics>;
42
+ type: string;
43
+ id: string | undefined;
44
+ data: ChannelData<ErmisChatGenerics> | ChannelResponse<ErmisChatGenerics> | undefined;
45
+ _data: ChannelData<ErmisChatGenerics> | ChannelResponse<ErmisChatGenerics>;
46
+ cid: string;
47
+ listeners: { [key: string]: (string | EventHandler<ErmisChatGenerics>)[] };
48
+ state: ChannelState<ErmisChatGenerics>;
49
+ initialized: boolean;
50
+ offlineMode: boolean;
51
+ lastKeyStroke?: Date;
52
+ lastTypingEvent: Date | null;
53
+ isTyping: boolean;
54
+ disconnected: boolean;
55
+
56
+ constructor(
57
+ client: ErmisChat<ErmisChatGenerics>,
58
+ type: string,
59
+ id: string | undefined,
60
+ data: ChannelData<ErmisChatGenerics>,
61
+ ) {
62
+ const validTypeRe = /^[\w_-]+$/;
63
+ const validIDRe = /^[\w!:_-]+$/;
64
+
65
+ if (!validTypeRe.test(type)) {
66
+ throw new Error(`Invalid chat type ${type}, letters, numbers and "_-" are allowed`);
67
+ }
68
+ if (typeof id === 'string' && !validIDRe.test(id)) {
69
+ throw new Error(`Invalid chat id ${id}, letters, numbers and "!-_" are allowed`);
70
+ }
71
+
72
+ this._client = client;
73
+ this.type = type;
74
+ this.id = id;
75
+ this.data = data;
76
+ this._data = { ...data };
77
+ this.cid = `${type}:${id}`;
78
+ this.listeners = {};
79
+ this.state = new ChannelState<ErmisChatGenerics>(this);
80
+ this.initialized = false;
81
+ this.offlineMode = false;
82
+ this.lastTypingEvent = null;
83
+ this.isTyping = false;
84
+ this.disconnected = false;
85
+ }
86
+
87
+ getClient(): ErmisChat<ErmisChatGenerics> {
88
+ return this._client;
89
+ }
90
+
91
+ async sendMessage(message: Message<ErmisChatGenerics>) {
92
+ // 1. Generate ID upfront
93
+ if (!message.id) {
94
+ message = { ...message, id: randomId() };
95
+ }
96
+ const messageId = message.id!;
97
+
98
+ // 2. Build optimistic (fake) message and push into state immediately
99
+ const optimisticMessage = {
100
+ ...message,
101
+ id: messageId,
102
+ status: 'sending',
103
+ created_at: new Date().toISOString(),
104
+ updated_at: new Date().toISOString(),
105
+ user: this.getClient().user,
106
+ user_id: this.getClient().userID,
107
+ type: 'regular',
108
+ } as unknown as MessageResponse<ErmisChatGenerics>;
109
+
110
+ this.state.addMessageSorted(optimisticMessage);
111
+
112
+ // 3. Call API — don't update status on success (WS message.new will handle it)
113
+ try {
114
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(
115
+ this._channelURL() + '/message',
116
+ { message: { ...message } },
117
+ );
118
+ } catch (error: any) {
119
+ // 4. On error: check if it's an offline/network error
120
+ const isOfflineError = !error.response || error.code === 'ERR_NETWORK' || error.isWSFailure || !this.getClient().wsConnection?.isHealthy;
121
+ const statusToSet = isOfflineError ? 'failed_offline' : 'error';
122
+ this.state.updateMessageStatus(messageId, statusToSet);
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ async retryMessage(messageId: string) {
128
+ const stateMsg = this.state.messages.find((m) => m.id === messageId);
129
+ if (!stateMsg) throw new Error(`Message ${messageId} not found in state`);
130
+
131
+ this.state.updateMessageStatus(messageId, 'sending');
132
+
133
+ const messagePayload: any = {
134
+ id: stateMsg.id,
135
+ text: stateMsg.text,
136
+ attachments: stateMsg.attachments,
137
+ mentioned_users: stateMsg.mentioned_users,
138
+ parent_id: stateMsg.parent_id,
139
+ quoted_message_id: stateMsg.quoted_message_id,
140
+ sticker_url: (stateMsg as any).sticker_url,
141
+ };
142
+
143
+ if (stateMsg.show_in_channel !== undefined) {
144
+ messagePayload.show_in_channel = stateMsg.show_in_channel;
145
+ }
146
+
147
+ try {
148
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(
149
+ this._channelURL() + '/message',
150
+ { message: messagePayload },
151
+ );
152
+ } catch (error: any) {
153
+ const isOfflineError = !error.response || error.code === 'ERR_NETWORK' || error.isWSFailure || !this.getClient().wsConnection?.isHealthy;
154
+ this.state.updateMessageStatus(messageId, isOfflineError ? 'failed_offline' : 'error');
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ async createPoll(pollMessage: PollMessage) {
160
+ const id = randomId();
161
+ pollMessage = { ...pollMessage, id };
162
+
163
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(this._channelURL() + '/message', {
164
+ message: { ...pollMessage },
165
+ });
166
+ }
167
+
168
+ async votePoll(messageID: string, pollChoice: string) {
169
+ if (!messageID) {
170
+ throw Error(`Message id is missing`);
171
+ }
172
+ return await this.getClient().post<APIResponse>(
173
+ this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageID}/poll/${pollChoice}`,
174
+ );
175
+ }
176
+
177
+ async forwardMessage(message: ForwardMessage<ErmisChatGenerics>, channel: { type: string; channelID: string }) {
178
+ if (!message.id) {
179
+ message = { ...message, id: randomId() };
180
+ }
181
+
182
+ return await this.getClient().post<SendMessageAPIResponse<ErmisChatGenerics>>(
183
+ `${this.getClient().baseURL}/channels/${channel.type}/${channel.channelID}` + '/message',
184
+ {
185
+ message: { ...message },
186
+ },
187
+ );
188
+ }
189
+
190
+ async pinMessage(messageID: string) {
191
+ return await this.getClient().post(this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageID}/pin`);
192
+ }
193
+
194
+ async unpinMessage(messageID: string) {
195
+ return await this.getClient().post(
196
+ this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageID}/unpin`,
197
+ );
198
+ }
199
+
200
+ async editMessage(oldMessageID: string, message: EditMessage) {
201
+ return await this.getClient().post(this.getClient().baseURL + `/messages/${this.type}/${this.id}/${oldMessageID}`, {
202
+ message,
203
+ });
204
+ }
205
+
206
+ sendFile(
207
+ uri: string | NodeJS.ReadableStream | Buffer | File,
208
+ name?: string,
209
+ contentType?: string,
210
+ user?: UserResponse<ErmisChatGenerics>,
211
+ ) {
212
+ return this.getClient().sendFile(`${this._channelURL()}/file`, uri, name, contentType, user);
213
+ }
214
+ /**
215
+ * Pre-process files (normalize names), upload them in parallel,
216
+ * generate video thumbnails, and build attachment payloads.
217
+ *
218
+ * @param files - Array of File objects to upload
219
+ * @param options - Optional voice recording metadata
220
+ * @returns `attachments` ready for sendMessage, and `failedFiles` for error display
221
+ */
222
+ async uploadAndPrepareAttachments(
223
+ files: File[],
224
+ options?: {
225
+ /** Map from file index → voice recording metadata */
226
+ voiceMetadata?: Map<number, VoiceRecordingMeta>;
227
+ },
228
+ ): Promise<{
229
+ attachments: Attachment[];
230
+ failedFiles: Array<{ file: File; error: Error }>;
231
+ }> {
232
+ const failedFiles: Array<{ file: File; error: Error }> = [];
233
+
234
+ // 1. Pre-process: normalize file names
235
+ const processedFiles = files.map((file) => {
236
+ const newName = normalizeFileName(file.name);
237
+ if (newName !== file.name) {
238
+ return new File([file], newName, { type: file.type, lastModified: file.lastModified });
239
+ }
240
+ return file;
241
+ });
242
+
243
+ // 2. Upload all files in parallel
244
+ const uploadResults = await Promise.allSettled(
245
+ processedFiles.map((file) =>
246
+ this.sendFile(file, file.name, file.type),
247
+ ),
248
+ );
249
+
250
+ // 3. For successful video uploads, generate and upload thumbnails
251
+ const thumbUrls = new Map<number, string>();
252
+ const thumbPromises: Promise<void>[] = [];
253
+
254
+ for (let i = 0; i < processedFiles.length; i++) {
255
+ const result = uploadResults[i];
256
+ if (result.status === 'fulfilled' && isVideoFile(processedFiles[i])) {
257
+ thumbPromises.push(
258
+ (async () => {
259
+ try {
260
+ const thumbBlob = await this.getThumbBlobVideo(files[i]);
261
+ if (thumbBlob) {
262
+ const thumbFile = new File(
263
+ [thumbBlob],
264
+ `thumb_${processedFiles[i].name}.jpg`,
265
+ { type: 'image/jpeg' },
266
+ );
267
+ const thumbResp = await this.sendFile(thumbFile, thumbFile.name, 'image/jpeg');
268
+ thumbUrls.set(i, thumbResp.file);
269
+ }
270
+ } catch {
271
+ // Thumbnail failure is non-critical
272
+ }
273
+ })(),
274
+ );
275
+ }
276
+ }
277
+
278
+ await Promise.allSettled(thumbPromises);
279
+
280
+ // 4. Build attachment payloads from successful uploads
281
+ const attachments: Attachment[] = [];
282
+ for (let i = 0; i < processedFiles.length; i++) {
283
+ const result = uploadResults[i];
284
+ if (result.status === 'fulfilled') {
285
+ const uploadedUrl = result.value.file;
286
+ const thumbUrl = thumbUrls.get(i);
287
+ const voiceMeta = options?.voiceMetadata?.get(i);
288
+ attachments.push(
289
+ buildAttachmentPayload(processedFiles[i], uploadedUrl, thumbUrl, voiceMeta),
290
+ );
291
+ } else {
292
+ failedFiles.push({
293
+ file: files[i],
294
+ error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
295
+ });
296
+ }
297
+ }
298
+
299
+ return { attachments, failedFiles };
300
+ }
301
+
302
+ async sendEvent(event: Event<ErmisChatGenerics>) {
303
+ // this._checkInitialized();
304
+ return await this.getClient().post(this._channelURL() + '/event', {
305
+ event,
306
+ });
307
+ }
308
+
309
+ async sendReaction(messageID: string, reactionType: string) {
310
+ if (!messageID) {
311
+ throw Error(`Message id is missing`);
312
+ }
313
+ return await this.getClient().post<ReactionAPIResponse<ErmisChatGenerics>>(
314
+ this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageID}/reaction/${reactionType}`,
315
+ );
316
+ }
317
+
318
+ deleteReaction(messageID: string, reactionType: string) {
319
+ // this._checkInitialized();
320
+ if (!reactionType || !messageID) {
321
+ throw Error('Deleting a reaction requires specifying both the message and reaction type');
322
+ }
323
+
324
+ const url = this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageID}/reaction/${reactionType}`;
325
+ //provided when server side request
326
+ // if (user_id) {
327
+ // return this.getClient().delete<ReactionAPIResponse<ErmisChatGenerics>>(url, { user_id });
328
+ // }
329
+
330
+ return this.getClient().delete<ReactionAPIResponse<ErmisChatGenerics>>(url, {});
331
+ }
332
+
333
+ async update(
334
+ channelData: Partial<ChannelData<ErmisChatGenerics>> | Partial<ChannelResponse<ErmisChatGenerics>> = {},
335
+ updateMessage?: Message<ErmisChatGenerics>,
336
+ ) {
337
+ // Strip out reserved names that will result in API errors.
338
+ const reserved = [
339
+ 'config',
340
+ 'cid',
341
+ 'created_by',
342
+ 'id',
343
+ 'member_count',
344
+ 'type',
345
+ 'created_at',
346
+ 'updated_at',
347
+ 'last_message_at',
348
+ 'own_capabilities',
349
+ ];
350
+ reserved.forEach((key) => {
351
+ delete channelData[key];
352
+ });
353
+
354
+ return await this._update({
355
+ message: updateMessage,
356
+ data: channelData,
357
+ });
358
+ }
359
+
360
+ async delete() {
361
+ return await this.getClient().delete(this._channelURL());
362
+ }
363
+
364
+ async truncate() {
365
+ return await this.getClient().delete(this._channelURL() + '/truncate');
366
+ }
367
+
368
+ async blockUser() {
369
+ return await this.getClient().post(this._channelURL(), { action: 'block' });
370
+ }
371
+
372
+ async unblockUser() {
373
+ return await this.getClient().post(this._channelURL(), { action: 'unblock' });
374
+ }
375
+
376
+ async acceptInvite(action: string) {
377
+ // const url = this.getClient().baseURL + `/invites/${this.type}/${this.id}/accept`;
378
+ const channel_id = this.id;
379
+
380
+ const url = this.getClient().userBaseURL + `/token_gate/join_channel/${this.type}`;
381
+ return this.getClient().post<APIResponse>(url, {}, { channel_id, action });
382
+ }
383
+
384
+ async rejectInvite() {
385
+ const url = this.getClient().baseURL + `/invites/${this.type}/${this.id}/reject`;
386
+ return this.getClient().post<APIResponse>(url);
387
+ }
388
+
389
+ async skipInvite() {
390
+ const url = this.getClient().baseURL + `/invites/${this.type}/${this.id}/skip`;
391
+ return this.getClient().post<APIResponse>(url);
392
+ }
393
+
394
+ async addMembers(members: string[]) {
395
+ return await this._update({ add_members: members });
396
+ }
397
+
398
+ async addModerators(members: string[]) {
399
+ return await this._update({ promote_members: members });
400
+ }
401
+
402
+ async banMembers(members: string[]) {
403
+ return await this._update({ ban_members: members });
404
+ }
405
+
406
+ async unbanMembers(members: string[]) {
407
+ return await this._update({ unban_members: members });
408
+ }
409
+
410
+ async updateCapabilities(capabilities: string[]) {
411
+ return await this._update({ capabilities });
412
+ }
413
+
414
+ /**
415
+ * Set slow mode (message cooldown) for the channel.
416
+ * Only applicable to team channels. Prevents members from sending
417
+ * messages faster than the specified cooldown interval.
418
+ *
419
+ * @param cooldown - Cooldown duration in milliseconds.
420
+ * Allowed values: 0 (off), 10000 (10s), 30000 (30s),
421
+ * 60000 (1min), 300000 (5min), 900000 (15min), 3600000 (1h).
422
+ */
423
+ async setSlowMode(cooldown: 0 | 10000 | 30000 | 60000 | 300000 | 900000 | 3600000) {
424
+ const allowedValues = [0, 10000, 30000, 60000, 300000, 900000, 3600000];
425
+ if (!allowedValues.includes(cooldown)) {
426
+ throw new Error(
427
+ `Invalid cooldown value: ${cooldown}. Allowed values are: ${allowedValues.join(', ')} (milliseconds).`,
428
+ );
429
+ }
430
+ return await this.update({ member_message_cooldown: cooldown } as any);
431
+ }
432
+
433
+ async queryAttachmentMessages() {
434
+ const response = await this.getClient().post<AttachmentResponse<ErmisChatGenerics>>(
435
+ this.getClient().baseURL + `/channels/${this.type}/${this.id}/attachment`,
436
+ {
437
+ attachment_types: ['image', 'video', 'file', 'voiceRecording', 'linkPreview'],
438
+ },
439
+ );
440
+
441
+ // Sort newest first
442
+ if (response.attachments) {
443
+ response.attachments.sort(
444
+ (a: any, b: any) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
445
+ );
446
+ }
447
+
448
+ return response;
449
+ }
450
+
451
+ async searchMessage(search_term: string, offset: number) {
452
+ const response: any = await this.getClient().post(this.getClient().baseURL + `/channels/search`, {
453
+ cid: this.cid,
454
+ search_term,
455
+ offset,
456
+ limit: 25,
457
+ });
458
+
459
+ if (!response || response?.search_result?.messages.length === 0) {
460
+ return null;
461
+ }
462
+
463
+ return {
464
+ ...response?.search_result,
465
+ messages: response?.search_result?.messages.map((message: any) => {
466
+ const user = getUserInfo(message.user_id, Object.values(this.getClient().state.users)) || message.user;
467
+ return { ...message, user };
468
+ }),
469
+ };
470
+ }
471
+
472
+ async removeMembers(members: string[]) {
473
+ return await this._update({ remove_members: members });
474
+ }
475
+
476
+ async demoteModerators(members: string[]) {
477
+ return await this._update({ demote_members: members });
478
+ }
479
+
480
+ async _update(payload: Object) {
481
+ const data = await this.getClient().post<UpdateChannelAPIResponse<ErmisChatGenerics>>(this._channelURL(), payload);
482
+ this.data = { ...this.data, ...data.channel };
483
+ return data;
484
+ }
485
+
486
+ _processTopics(topicsFromApi: any, users: any[]) {
487
+ const topics = topicsFromApi.map((topic: any) => {
488
+ // Enrich topic members with user info
489
+ if (topic.channel && topic.channel.members) {
490
+ topic.channel.members = enrichWithUserInfo(topic.channel.members, users);
491
+ }
492
+ // Enrich topic messages with user info
493
+ if (topic.messages) {
494
+ topic.messages = enrichWithUserInfo(topic.messages, users);
495
+ }
496
+ // Enrich topic pinned messages with user info
497
+ if (topic.pinned_messages) {
498
+ topic.pinned_messages = enrichWithUserInfo(topic.pinned_messages, users);
499
+ }
500
+ // Enrich topic read with user info
501
+ if (topic.read) {
502
+ topic.read = enrichWithUserInfo(topic.read, users);
503
+ }
504
+ return topic;
505
+ });
506
+
507
+ const { channels } = this.getClient().hydrateChannels(topics, {});
508
+
509
+ // Store topics in channel state
510
+ this.state.topics = channels;
511
+ }
512
+
513
+ async muteNotification(duration: number | null) {
514
+ return await this.getClient().post<AttachmentResponse<ErmisChatGenerics>>(
515
+ this.getClient().baseURL + `/channels/${this.type}/${this.id}/muted`,
516
+ { mute: true, duration },
517
+ );
518
+ }
519
+
520
+ async unMuteNotification() {
521
+ return await this.getClient().post<AttachmentResponse<ErmisChatGenerics>>(
522
+ this.getClient().baseURL + `/channels/${this.type}/${this.id}/muted`,
523
+ { mute: false },
524
+ );
525
+ }
526
+
527
+ async keystroke(parent_id?: string, options?: { user_id: string }) {
528
+ const now = new Date();
529
+ const diff = this.lastTypingEvent && now.getTime() - this.lastTypingEvent.getTime();
530
+ this.lastKeyStroke = now;
531
+ this.isTyping = true;
532
+ // send a typing.start every 2 seconds
533
+ if (diff === null || diff > 2000) {
534
+ this.lastTypingEvent = new Date();
535
+ await this.sendEvent({
536
+ type: 'typing.start',
537
+ parent_id,
538
+ ...(options || {}),
539
+ } as Event<ErmisChatGenerics>);
540
+ }
541
+ }
542
+
543
+ async stopTyping(parent_id?: string, options?: { user_id: string }) {
544
+ if (!this.isTyping) return;
545
+ this.lastTypingEvent = null;
546
+ this.isTyping = false;
547
+ await this.sendEvent({
548
+ type: 'typing.stop',
549
+ parent_id,
550
+ ...(options || {}),
551
+ } as Event<ErmisChatGenerics>);
552
+ }
553
+
554
+ _isTypingIndicatorsEnabled(): boolean {
555
+ return true;
556
+ }
557
+
558
+ lastMessage() {
559
+ let min = this.state.latestMessages.length - 5;
560
+ if (min < 0) {
561
+ min = 0;
562
+ }
563
+ const max = this.state.latestMessages.length + 1;
564
+ const messageSlice = this.state.latestMessages.slice(min, max);
565
+
566
+ // sort by pk desc
567
+ messageSlice.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
568
+
569
+ return messageSlice[0];
570
+ }
571
+
572
+ async markRead() {
573
+ return await this.getClient().post(this._channelURL() + '/read');
574
+ }
575
+
576
+ clean() {
577
+ if (this.lastKeyStroke) {
578
+ const now = new Date();
579
+ const diff = now.getTime() - this.lastKeyStroke.getTime();
580
+ if (diff > 1000 && this.isTyping) {
581
+ logChatPromiseExecution(this.stopTyping(), 'stop typing event');
582
+ }
583
+ }
584
+
585
+ this.state.clean();
586
+ }
587
+
588
+ async watch(options?: ChannelQueryOptions) {
589
+ // Make sure we wait for the connect promise if there is a pending one
590
+ await this.getClient().wsPromise;
591
+
592
+ const combined = { ...options };
593
+ const state = await this.query(combined, 'latest');
594
+ this.initialized = true;
595
+ // Ensure all members' user info are loaded in state.users
596
+ await ensureMembersUserInfoLoaded(this.getClient(), state.channel.members);
597
+
598
+ // Get the latest users after updating
599
+ const users = Object.values(this.getClient().state.users);
600
+ state.channel.members = enrichWithUserInfo(state.channel.members, users);
601
+ state.channel.name =
602
+ state.channel.type === 'messaging'
603
+ ? getDirectChannelName(state.channel.members, this.getClient().userID || '')
604
+ : state.channel.name;
605
+ state.channel.image =
606
+ state.channel.type === 'messaging'
607
+ ? getDirectChannelImage(state.channel.members, this.getClient().userID || '')
608
+ : state.channel.image;
609
+ state.messages = enrichWithUserInfo(state.messages, users);
610
+ state.pinned_messages = state.pinned_messages ? enrichWithUserInfo(state.pinned_messages, users) : [];
611
+ state.read = enrichWithUserInfo(state.read || [], users);
612
+
613
+ // Process topics for team channels (already handled in query, but ensuring consistency)
614
+ if (this.type === 'team' && state.channel.topics_enabled) {
615
+ const payload = {
616
+ filter_conditions: { type: ['topic'], parent_cid: this.cid, project_id: this.getClient().projectId },
617
+ sort: [],
618
+ message_limit: 25,
619
+ };
620
+ const topicsFromApi: any = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(
621
+ this.getClient().baseURL + '/channels',
622
+ payload,
623
+ );
624
+
625
+ this._processTopics(topicsFromApi.channels || [], users);
626
+ }
627
+
628
+ this.data = state.channel;
629
+
630
+ this._client.logger('info', `channel:watch() - started watching channel ${this.cid}`, {
631
+ tags: ['channel'],
632
+ channel: this,
633
+ });
634
+ return state;
635
+ }
636
+
637
+ lastRead() {
638
+ const { userID } = this.getClient();
639
+ if (userID) {
640
+ return this.state.read[userID] ? this.state.read[userID].last_read : null;
641
+ }
642
+ }
643
+ // TODO: KhoaKheu Add mute Users later, confict here
644
+ _countMessageAsUnread(message: FormatMessageResponse<ErmisChatGenerics> | MessageResponse<ErmisChatGenerics>) {
645
+ if (message.parent_id && !message.show_in_channel) return false;
646
+ if (message.user?.id === this.getClient().userID) return false;
647
+ if (message.type === 'system') return false;
648
+
649
+ // Return false if channel doesn't allow read events.
650
+ if (Array.isArray(this.data?.own_capabilities) && !this.data?.own_capabilities.includes('read-events'))
651
+ return false;
652
+
653
+ return true;
654
+ }
655
+
656
+ countUnread(lastRead?: Date | null) {
657
+ if (!lastRead) return this.state.unreadCount;
658
+
659
+ let count = 0;
660
+ for (let i = 0; i < this.state.latestMessages.length; i += 1) {
661
+ const message = this.state.latestMessages[i];
662
+ if (message.created_at > lastRead && this._countMessageAsUnread(message)) {
663
+ count++;
664
+ }
665
+ }
666
+ return count;
667
+ }
668
+
669
+ getUnreadMemberCount() {
670
+ if (!this.state.read) return [];
671
+
672
+ return Object.values(this.state.read);
673
+ }
674
+
675
+ getCapabilitiesMember() {
676
+ if (!this.data) return [];
677
+
678
+ return this.data.member_capabilities;
679
+ }
680
+
681
+ create = async () => {
682
+ if (this.type === 'messaging') {
683
+ return await this.createDirectChannel('latest');
684
+ } else {
685
+ return await this.query({}, 'latest');
686
+ }
687
+ };
688
+
689
+ async createTopic(data: any) {
690
+ const project_id = this._client.projectId;
691
+ const uuid = randomId();
692
+ const topicID = `${project_id}:${uuid}`;
693
+
694
+ const queryURL = `${this.getClient().baseURL}/channels/topic/${topicID}`;
695
+ const payload: any = {
696
+ project_id,
697
+ parent_cid: this.cid,
698
+ data: { ...data },
699
+ };
700
+
701
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', payload);
702
+
703
+ return state;
704
+ }
705
+
706
+ async query(options: ChannelQueryOptions, messageSetToAddToIfDoesNotExist: MessageSetType = 'current') {
707
+ // Make sure we wait for the connect promise if there is a pending one
708
+ await this.getClient().wsPromise;
709
+
710
+ let project_id = this._client.projectId;
711
+ let update_options = { ...options, project_id };
712
+
713
+ let queryURL = `${this.getClient().baseURL}/channels/${this.type}`;
714
+ if (this.id) {
715
+ queryURL += `/${this.id}`;
716
+ } else {
717
+ if (this.type === 'team') {
718
+ const uuid = randomId();
719
+ this.id = `${project_id}:${uuid}`;
720
+ queryURL += `/${this.id}`;
721
+ }
722
+ }
723
+
724
+ const payload: any = {
725
+ state: true,
726
+ ...update_options,
727
+ };
728
+
729
+ if (this._data && Object.keys(this._data).length > 0) {
730
+ payload.data = this._data;
731
+ }
732
+
733
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', payload);
734
+ // Ensure all members' user info are loaded in state.users
735
+ await ensureMembersUserInfoLoaded(this.getClient(), state.channel.members);
736
+ const users = Object.values(this.getClient().state.users);
737
+ state.channel.members = enrichWithUserInfo(state.channel.members, users);
738
+ state.channel.name =
739
+ state.channel.type === 'messaging'
740
+ ? getDirectChannelName(state.channel.members, this.getClient().userID || '')
741
+ : state.channel.name;
742
+ state.channel.image =
743
+ state.channel.type === 'messaging'
744
+ ? getDirectChannelImage(state.channel.members, this.getClient().userID || '')
745
+ : state.channel.image;
746
+ state.messages = enrichWithUserInfo(state.messages, users);
747
+ state.pinned_messages = state.pinned_messages ? enrichWithUserInfo(state.pinned_messages, users) : [];
748
+ state.read = enrichWithUserInfo(state.read || [], users);
749
+ state.channel.is_pinned = state.is_pinned || false;
750
+
751
+ // Process topics for team channels
752
+ // if (this.type === 'team' && state.channel.topics_enabled && state.topics) {
753
+ // this._processTopics(state.topics, users);
754
+ // }
755
+
756
+ // update the channel id if it was missing
757
+
758
+ if (!this.id) {
759
+ this.id = state.channel.id;
760
+ this.cid = state.channel.cid;
761
+
762
+ // set the channel as active...
763
+ const membersStr = state.channel.members
764
+ .map((member) => member.user_id || member.user?.id)
765
+ .sort()
766
+ .join(',');
767
+ const tempChannelCid = `${this.type}:!members-${membersStr}`;
768
+
769
+ if (tempChannelCid in this.getClient().activeChannels) {
770
+ // This gets set in `client.channel()` function, when channel is created
771
+ // using members, not id.
772
+ delete this.getClient().activeChannels[tempChannelCid];
773
+ }
774
+
775
+ if (!(this.cid in this.getClient().activeChannels)) {
776
+ this.getClient().activeChannels[this.cid] = this;
777
+ }
778
+ }
779
+
780
+ // add any messages to our channel state
781
+ const { messageSet } = this._initializeState(state, messageSetToAddToIfDoesNotExist);
782
+
783
+ const areCapabilitiesChanged =
784
+ [...(state.channel.own_capabilities || [])].sort().join() !==
785
+ [...(Array.isArray(this.data?.own_capabilities) ? (this.data?.own_capabilities as string[]) : [])].sort().join();
786
+ this.data = state.channel;
787
+ this.offlineMode = false;
788
+
789
+ if (areCapabilitiesChanged) {
790
+ this.getClient().dispatchEvent({
791
+ type: 'capabilities.changed',
792
+ cid: this.cid,
793
+ own_capabilities: state.channel.own_capabilities,
794
+ });
795
+ }
796
+
797
+ return state;
798
+ }
799
+
800
+ async createDirectChannel(messageSetToAddToIfDoesNotExist: MessageSetType = 'current') {
801
+ // Make sure we wait for the connect promise if there is a pending one
802
+ await this.getClient().wsPromise;
803
+
804
+ const project_id = this._client.projectId;
805
+
806
+ const queryURL = `${this.getClient().baseURL}/channels/${this.type}`;
807
+
808
+ const payload: any = {
809
+ project_id,
810
+ };
811
+
812
+ if (this._data && Object.keys(this._data).length > 0) {
813
+ payload.data = this._data;
814
+ }
815
+
816
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', payload);
817
+
818
+ const users = Object.values(this.getClient().state.users);
819
+ state.channel.members = enrichWithUserInfo(state.channel.members, users);
820
+ state.channel.name =
821
+ state.channel.type === 'messaging'
822
+ ? getDirectChannelName(state.channel.members, this.getClient().userID || '')
823
+ : state.channel.name;
824
+ state.messages = enrichWithUserInfo(state.messages, users);
825
+ state.pinned_messages = state.pinned_messages ? enrichWithUserInfo(state.pinned_messages, users) : [];
826
+ state.read = enrichWithUserInfo(state.read || [], users);
827
+
828
+ // add any messages to our channel state
829
+ const { messageSet } = this._initializeState(state, messageSetToAddToIfDoesNotExist);
830
+
831
+ const areCapabilitiesChanged =
832
+ [...(state.channel.own_capabilities || [])].sort().join() !==
833
+ [...(Array.isArray(this.data?.own_capabilities) ? (this.data?.own_capabilities as string[]) : [])].sort().join();
834
+ this.data = state.channel;
835
+ this.offlineMode = false;
836
+
837
+ if (areCapabilitiesChanged) {
838
+ this.getClient().dispatchEvent({
839
+ type: 'capabilities.changed',
840
+ cid: state.channel.cid,
841
+ own_capabilities: state.channel.own_capabilities,
842
+ });
843
+ }
844
+
845
+ return state;
846
+ }
847
+
848
+ async queryMessagesLessThanId(message_id: string, limit: number = 25) {
849
+ await this.getClient().wsPromise;
850
+
851
+ let project_id = this._client.projectId;
852
+ let queryURL = `${this.getClient().baseURL}/channels/${this.type}/${this.id}`;
853
+
854
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', {
855
+ // data: this._data,
856
+ state: true,
857
+ project_id,
858
+ messages: { limit, id_lt: message_id },
859
+ });
860
+
861
+ const users = Object.values(this.getClient().state.users);
862
+ state.messages = enrichWithUserInfo(state.messages, users);
863
+ if (state.messages && state.messages.length > 0) {
864
+ for (const msg of state.messages) {
865
+ if (!msg.pinned) {
866
+ const pm = this.state.pinnedMessages?.find((p) => p.id === msg.id);
867
+ if (pm) {
868
+ msg.pinned = true;
869
+ const pmDate = pm.pinned_at || new Date();
870
+ msg.pinned_at = typeof pmDate === 'string' ? pmDate : pmDate.toISOString();
871
+ }
872
+ }
873
+ }
874
+ this.state.addMessagesSorted(state.messages, false, true, true, 'current');
875
+ }
876
+ return state.messages;
877
+ }
878
+
879
+ async queryMessagesGreaterThanId(message_id: string, limit: number = 25) {
880
+ await this.getClient().wsPromise;
881
+
882
+ let project_id = this._client.projectId;
883
+ let queryURL = `${this.getClient().baseURL}/channels/${this.type}/${this.id}`;
884
+
885
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', {
886
+ // data: this._data,
887
+ state: true,
888
+ project_id,
889
+ messages: { limit, id_gt: message_id },
890
+ });
891
+
892
+ const users = Object.values(this.getClient().state.users);
893
+ state.messages = enrichWithUserInfo(state.messages, users);
894
+ if (state.messages && state.messages.length > 0) {
895
+ for (const msg of state.messages) {
896
+ if (!msg.pinned) {
897
+ const pm = this.state.pinnedMessages?.find((p) => p.id === msg.id);
898
+ if (pm) {
899
+ msg.pinned = true;
900
+ const pmDate = pm.pinned_at || new Date();
901
+ msg.pinned_at = typeof pmDate === 'string' ? pmDate : pmDate.toISOString();
902
+ }
903
+ }
904
+ }
905
+ this.state.addMessagesSorted(state.messages, false, true, true, 'current');
906
+ }
907
+ return state.messages;
908
+ }
909
+
910
+ async queryMessagesAroundId(message_id: string, limit: number = 25) {
911
+ await this.getClient().wsPromise;
912
+
913
+ let project_id = this._client.projectId;
914
+ let queryURL = `${this.getClient().baseURL}/channels/${this.type}/${this.id}`;
915
+
916
+ const state = await this.getClient().post<QueryChannelAPIResponse<ErmisChatGenerics>>(queryURL + '/query', {
917
+ // data: this._data,
918
+ state: true,
919
+ project_id,
920
+ messages: { limit, id_around: message_id },
921
+ });
922
+
923
+ const users = Object.values(this.getClient().state.users);
924
+ state.messages = enrichWithUserInfo(state.messages, users);
925
+ if (state.messages && state.messages.length > 0) {
926
+ for (const msg of state.messages) {
927
+ if (!msg.pinned) {
928
+ const pm = this.state.pinnedMessages?.find((p) => p.id === msg.id);
929
+ if (pm) {
930
+ msg.pinned = true;
931
+ const pmDate = pm.pinned_at || new Date();
932
+ msg.pinned_at = typeof pmDate === 'string' ? pmDate : pmDate.toISOString();
933
+ }
934
+ }
935
+ }
936
+ this.state.addMessagesSorted(state.messages, false, true, true, 'current');
937
+ }
938
+ return state.messages;
939
+ }
940
+
941
+ async deleteMessage(messageId: string) {
942
+ return await this.getClient().delete<APIResponse & { message: MessageResponse<ErmisChatGenerics> }>(
943
+ this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageId}`,
944
+ );
945
+ }
946
+
947
+ async deleteMessageForMe(messageId: string) {
948
+ return await this.getClient().delete<APIResponse & { message: MessageResponse<ErmisChatGenerics> }>(
949
+ this.getClient().baseURL + `/messages/${this.type}/${this.id}/${messageId}`,
950
+ { for_me: true },
951
+ );
952
+ }
953
+
954
+ async getThumbBlobVideo(file: File): Promise<Blob | null> {
955
+ return new Promise((resolve) => {
956
+ const seekTo = 0.1;
957
+ const videoPlayer = document.createElement('video');
958
+ videoPlayer.src = URL.createObjectURL(file);
959
+ videoPlayer.crossOrigin = 'anonymous'; // Tránh lỗi CORS nếu cần
960
+ videoPlayer.load();
961
+
962
+ videoPlayer.addEventListener('error', () => {
963
+ console.error('Error when loading video file.');
964
+ resolve(null);
965
+ });
966
+
967
+ videoPlayer.addEventListener('loadedmetadata', () => {
968
+ if (videoPlayer.duration < seekTo) {
969
+ console.error('Video is too short.');
970
+ resolve(null);
971
+ return;
972
+ }
973
+
974
+ setTimeout(() => {
975
+ videoPlayer.currentTime = seekTo;
976
+ }, 200);
977
+ });
978
+
979
+ videoPlayer.addEventListener('seeked', () => {
980
+ try {
981
+ const canvas = document.createElement('canvas');
982
+ canvas.width = videoPlayer.videoWidth;
983
+ canvas.height = videoPlayer.videoHeight;
984
+ const ctx = canvas.getContext('2d');
985
+
986
+ if (!ctx) {
987
+ console.error('Failed to create canvas context.');
988
+ resolve(null);
989
+ return;
990
+ }
991
+
992
+ ctx.drawImage(videoPlayer, 0, 0, canvas.width, canvas.height);
993
+
994
+ ctx.canvas.toBlob(
995
+ (blob) => {
996
+ if (!blob) {
997
+ console.error('Failed to generate thumbnail.');
998
+ resolve(null);
999
+ return;
1000
+ }
1001
+ resolve(blob);
1002
+ URL.revokeObjectURL(videoPlayer.src); // Giải phóng bộ nhớ
1003
+ },
1004
+ 'image/jpeg',
1005
+ 0.75,
1006
+ );
1007
+ } catch (error) {
1008
+ console.error('Error while extracting thumbnail:', error);
1009
+ resolve(null);
1010
+ }
1011
+ });
1012
+ });
1013
+ }
1014
+
1015
+ async enableTopics() {
1016
+ return await this.getClient().post(this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics/enable`, {
1017
+ project_id: this.getClient().projectId,
1018
+ messages: { limit: 25 },
1019
+ });
1020
+ }
1021
+
1022
+ async disableTopics() {
1023
+ return await this.getClient().post(this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics/disable`, {
1024
+ project_id: this.getClient().projectId,
1025
+ });
1026
+ }
1027
+
1028
+ async closeTopic(topicCID: string) {
1029
+ return await this.getClient().post(this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics/close`, {
1030
+ project_id: this.getClient().projectId,
1031
+ topic_cid: topicCID,
1032
+ });
1033
+ }
1034
+
1035
+ async reopenTopic(topicCID: string) {
1036
+ return await this.getClient().post(this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics/reopen`, {
1037
+ project_id: this.getClient().projectId,
1038
+ topic_cid: topicCID,
1039
+ });
1040
+ }
1041
+
1042
+ async editTopic(topicCID: string, data: any) {
1043
+ const response: any = await this.getClient().post(
1044
+ this.getClient().baseURL + `/channels/${this.type}/${this.id}/topics`,
1045
+ {
1046
+ project_id: this.getClient().projectId,
1047
+ topic_cid: topicCID,
1048
+ data,
1049
+ },
1050
+ );
1051
+
1052
+ if (response) {
1053
+ const activeTopic = this.getClient().activeChannels[topicCID];
1054
+
1055
+ if (activeTopic) {
1056
+ activeTopic.data = response.channel;
1057
+ return activeTopic.data;
1058
+ } else {
1059
+ return response.channel;
1060
+ }
1061
+ }
1062
+ }
1063
+
1064
+ on(eventType: EventTypes, callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
1065
+ on(callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
1066
+ on(
1067
+ callbackOrString: EventHandler<ErmisChatGenerics> | EventTypes,
1068
+ callbackOrNothing?: EventHandler<ErmisChatGenerics>,
1069
+ ): { unsubscribe: () => void } {
1070
+ const key = callbackOrNothing ? (callbackOrString as string) : 'all';
1071
+ const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
1072
+ if (!(key in this.listeners)) {
1073
+ this.listeners[key] = [];
1074
+ }
1075
+ this._client.logger('info', `Attaching listener for ${key} event on channel ${this.cid}`, {
1076
+ tags: ['event', 'channel'],
1077
+ channel: this,
1078
+ });
1079
+
1080
+ this.listeners[key].push(callback);
1081
+
1082
+ return {
1083
+ unsubscribe: () => {
1084
+ this._client.logger('info', `Removing listener for ${key} event from channel ${this.cid}`, {
1085
+ tags: ['event', 'channel'],
1086
+ channel: this,
1087
+ });
1088
+
1089
+ this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
1090
+ },
1091
+ };
1092
+ }
1093
+
1094
+ off(eventType: EventTypes, callback: EventHandler<ErmisChatGenerics>): void;
1095
+ off(callback: EventHandler<ErmisChatGenerics>): void;
1096
+ off(
1097
+ callbackOrString: EventHandler<ErmisChatGenerics> | EventTypes,
1098
+ callbackOrNothing?: EventHandler<ErmisChatGenerics>,
1099
+ ): void {
1100
+ const key = callbackOrNothing ? (callbackOrString as string) : 'all';
1101
+ const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
1102
+ if (!(key in this.listeners)) {
1103
+ this.listeners[key] = [];
1104
+ }
1105
+
1106
+ this._client.logger('info', `Removing listener for ${key} event from channel ${this.cid}`, {
1107
+ tags: ['event', 'channel'],
1108
+ channel: this,
1109
+ });
1110
+ this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
1111
+ }
1112
+
1113
+ // eslint-disable-next-line sonarjs/cognitive-complexity
1114
+ async _handleChannelEvent(event: Event<ErmisChatGenerics>) {
1115
+ const channel = this;
1116
+ this._client.logger(
1117
+ 'info',
1118
+ `channel:_handleChannelEvent - Received event of type { ${event.type} } on ${this.cid}`,
1119
+ {
1120
+ tags: ['event', 'channel'],
1121
+ channel: this,
1122
+ },
1123
+ );
1124
+
1125
+ const channelState = channel.state;
1126
+ const users = Object.values(this.getClient().state.users);
1127
+ switch (event.type) {
1128
+ case 'typing.start':
1129
+ if (event.user?.id) {
1130
+ const user = getUserInfo(event.user.id || '', users);
1131
+ event.user = user;
1132
+ channelState.typing[event.user.id] = event;
1133
+ }
1134
+ break;
1135
+ case 'typing.stop':
1136
+ if (event.user?.id) {
1137
+ delete channelState.typing[event.user.id];
1138
+ }
1139
+ break;
1140
+ case 'message.read':
1141
+ if (event.user?.id && event.created_at) {
1142
+ const user = getUserInfo(event.user.id || '', users);
1143
+ event.user = user;
1144
+ channelState.read[event.user.id] = {
1145
+ last_read: new Date(event.created_at),
1146
+ last_read_message_id: event.last_read_message_id,
1147
+ user,
1148
+ unread_messages: 0,
1149
+ };
1150
+
1151
+ if (event.user?.id === this.getClient().user?.id) {
1152
+ channelState.unreadCount = 0;
1153
+ }
1154
+ }
1155
+ break;
1156
+ case 'user.watching.start':
1157
+ if (event.user?.id) {
1158
+ channelState.watchers[event.user.id] = event.user;
1159
+ }
1160
+ break;
1161
+ case 'user.watching.stop':
1162
+ if (event.user?.id) {
1163
+ delete channelState.watchers[event.user.id];
1164
+ }
1165
+ break;
1166
+ case 'message.deleted':
1167
+ if (event.message) {
1168
+ this._extendEventWithOwnReactions(event);
1169
+ //! NOTE: check lai o day
1170
+ channelState.removeMessage(event.message);
1171
+ channelState.addMessageSorted(event.message, false, false);
1172
+ // if (event.hard_delete) channelState.removeMessage(event.message);
1173
+ // else channelState.addMessageSorted(event.message, false, false);
1174
+
1175
+ channelState.removeQuotedMessageReferences(event.message);
1176
+
1177
+ // if (event.message.pinned) {
1178
+ // channelState.removePinnedMessage(event.message);
1179
+ // }
1180
+
1181
+ if ([...channelState.pinnedMessages].some((msg) => msg.id === event.message?.id)) {
1182
+ channelState.removePinnedMessage(event.message);
1183
+ }
1184
+
1185
+ for (const userId in channelState.read) {
1186
+ if (userId !== event.user?.id && event.message.id === channelState.read[userId].last_read_message_id) {
1187
+ // Clear last_read_message_id if the deleted message is the last_read_message_id
1188
+ channelState.read[userId] = { ...channelState.read[userId], last_read_message_id: undefined };
1189
+ }
1190
+ }
1191
+ }
1192
+ break;
1193
+ case 'message.deleted_for_me':
1194
+ if (event.message) {
1195
+ channelState.removeMessage(event.message);
1196
+ channelState.removeQuotedMessageReferences(event.message);
1197
+
1198
+ if ([...channelState.pinnedMessages].some((msg) => msg.id === event.message?.id)) {
1199
+ channelState.removePinnedMessage(event.message);
1200
+ }
1201
+ }
1202
+ break;
1203
+ case 'message.new':
1204
+ if (event.message) {
1205
+ /* if message belongs to current user, always assume timestamp is changed to filter it out and add again to avoid duplication */
1206
+ const ownMessage = event.user?.id === this.getClient().user?.id;
1207
+ const isThreadMessage = !!event.message.parent_id;
1208
+
1209
+ const existUser = users.find((user) => user.id === event.user?.id);
1210
+ if (!existUser) {
1211
+ if (event.user?.id) {
1212
+ const resUser = await this.getClient().queryUser(event.user.id);
1213
+ users.push(resUser);
1214
+ }
1215
+ }
1216
+
1217
+ const userInfo = getUserInfo(event.user?.id || '', users);
1218
+ event.message.user = userInfo;
1219
+ if (event.message?.quoted_message) {
1220
+ const quotedUser = getUserInfo(event.message.quoted_message.user?.id || '', users);
1221
+ event.message.quoted_message.user = quotedUser;
1222
+ }
1223
+ event.user = userInfo;
1224
+
1225
+ if (this.state.isUpToDate || isThreadMessage) {
1226
+ channelState.addMessageSorted(event.message, ownMessage);
1227
+ }
1228
+ // if (event.message.pinned) {
1229
+ // channelState.addPinnedMessage(event.message);
1230
+ // }
1231
+
1232
+ // do not increase the unread count - the back-end does not increase the count neither in the following cases:
1233
+ // 1. the message is mine
1234
+ // 2. the message is a thread reply from any user
1235
+ const preventUnreadCountUpdate = ownMessage || isThreadMessage;
1236
+ if (preventUnreadCountUpdate) break;
1237
+
1238
+ if (event.user?.id) {
1239
+ for (const userId in channelState.read) {
1240
+ if (userId === event.user.id) {
1241
+ channelState.read[event.user.id] = {
1242
+ last_read: new Date(event.created_at as string),
1243
+ user: event.user,
1244
+ unread_messages: 0,
1245
+ };
1246
+ } else {
1247
+ channelState.read[userId].unread_messages += 1;
1248
+ }
1249
+ }
1250
+ }
1251
+
1252
+ if (this._countMessageAsUnread(event.message)) {
1253
+ channelState.unreadCount = channelState.unreadCount + 1;
1254
+ }
1255
+ }
1256
+ break;
1257
+ case 'message.updated':
1258
+ if (event.message) {
1259
+ const userEvent = getUserInfo(event.user?.id || '', users);
1260
+ const userMsg = getUserInfo(event.message.user?.id || '', users);
1261
+ event.user = userEvent;
1262
+ event.message.user = userMsg;
1263
+
1264
+ if (event.message?.quoted_message) {
1265
+ const quotedUser = getUserInfo(event.message.quoted_message.user?.id || '', users);
1266
+ event.message.quoted_message.user = quotedUser;
1267
+ }
1268
+
1269
+ if (event.message?.latest_reactions) {
1270
+ event.message.latest_reactions = enrichWithUserInfo(event.message.latest_reactions || [], users);
1271
+ }
1272
+
1273
+ this._extendEventWithOwnReactions(event);
1274
+ channelState.addMessageSorted(event.message, false, false);
1275
+ if (event.message.pinned) {
1276
+ channelState.addPinnedMessage(event.message);
1277
+ } else {
1278
+ channelState.removePinnedMessage(event.message);
1279
+ }
1280
+ }
1281
+ break;
1282
+ case 'message.pinned':
1283
+ if (event.message) {
1284
+ const user = getUserInfo(event.message.user?.id || '', users);
1285
+ event.message.user = user;
1286
+ channelState.addPinnedMessage(event.message);
1287
+ channelState.addMessageSorted(event.message, false, false);
1288
+ }
1289
+ break;
1290
+ case 'message.unpinned':
1291
+ if (event.message) {
1292
+ const user = getUserInfo(event.message.user?.id || '', users);
1293
+ event.message.user = user;
1294
+ channelState.removePinnedMessage(event.message);
1295
+ channelState.addMessageSorted(event.message, false, false);
1296
+ }
1297
+ break;
1298
+ case 'channel.truncate':
1299
+ if (event.channel?.created_at) {
1300
+ const truncatedAt = +new Date(event.channel.created_at);
1301
+
1302
+ channelState.messageSets.forEach((messageSet, messageSetIndex) => {
1303
+ messageSet.messages.forEach(({ created_at: createdAt, id }) => {
1304
+ if (truncatedAt > +createdAt) channelState.removeMessage({ id, messageSetIndex });
1305
+ });
1306
+ });
1307
+
1308
+ channelState.pinnedMessages.forEach(({ id, created_at: createdAt }) => {
1309
+ if (truncatedAt > +createdAt)
1310
+ channelState.removePinnedMessage({ id } as MessageResponse<ErmisChatGenerics>);
1311
+ });
1312
+ } else {
1313
+ channelState.clearMessages();
1314
+ }
1315
+
1316
+ channelState.unreadCount = 0;
1317
+ // system messages don't increment unread counts
1318
+ if (event.message) {
1319
+ channelState.addMessageSorted(event.message);
1320
+ if (event.message.pinned) {
1321
+ channelState.addPinnedMessage(event.message);
1322
+ }
1323
+ }
1324
+ break;
1325
+ case 'member.added':
1326
+ if (event.member?.user_id) {
1327
+ const user = getUserInfo(event.member.user_id, users);
1328
+ event.member.user = user;
1329
+
1330
+ channelState.members[event.member.user_id] = event.member;
1331
+
1332
+ if (event.member.user?.id === this.getClient().user?.id) {
1333
+ channelState.membership = event.member;
1334
+ }
1335
+ }
1336
+ break;
1337
+ case 'member.updated':
1338
+ if (event.member?.user_id) {
1339
+ const user = getUserInfo(event.member.user_id, users);
1340
+ event.member.user = user;
1341
+ channelState.members[event.member.user_id] = event.member;
1342
+ channelState.membership = event.member;
1343
+ }
1344
+ break;
1345
+ case 'member.removed':
1346
+ if (event.member?.user_id) {
1347
+ delete channelState.members[event.member.user_id];
1348
+ } else if (event.user?.id) {
1349
+ // fallback just in case some legacy payload uses event.user for the removed user
1350
+ delete channelState.members[event.user.id];
1351
+ }
1352
+ break;
1353
+ case 'channel.updated':
1354
+ if (event.channel) {
1355
+ channel.data = {
1356
+ ...channel.data,
1357
+ ...event.channel,
1358
+ own_capabilities: event.channel?.own_capabilities ?? channel.data?.own_capabilities,
1359
+ };
1360
+ }
1361
+ break;
1362
+ case 'pollchoice.new':
1363
+ if (event.message) {
1364
+ const user = getUserInfo(event.message.user?.id || '', users);
1365
+ event.message.user = user;
1366
+ channelState.addMessageSorted(event.message, false, false);
1367
+ }
1368
+ break;
1369
+ case 'reaction.new':
1370
+ if (event.message && event.reaction) {
1371
+ const userMsg = getUserInfo(event.message.user?.id || '', users);
1372
+ const userReaction = getUserInfo(event.reaction.user?.id || '', users);
1373
+ event.message.user = userMsg;
1374
+ event.message.latest_reactions = enrichWithUserInfo(event.message.latest_reactions || [], users);
1375
+ event.reaction.user = userReaction;
1376
+ if (event.message?.quoted_message) {
1377
+ const quotedUser = getUserInfo(event.message.quoted_message.user?.id || '', users);
1378
+ event.message.quoted_message.user = quotedUser;
1379
+ }
1380
+ event.message = channelState.addReaction(event.reaction, event.message);
1381
+ }
1382
+ break;
1383
+ case 'reaction.deleted':
1384
+ event.user = getUserInfo(event.user?.id || '', users);
1385
+ if (event.message) {
1386
+ if (event.message?.quoted_message) {
1387
+ const quotedUser = getUserInfo(event.message.quoted_message.user?.id || '', users);
1388
+ event.message.quoted_message.user = quotedUser;
1389
+ }
1390
+ event.message.user = getUserInfo(event.message.user?.id || '', users);
1391
+ event.message.latest_reactions?.map((item) => {
1392
+ item.user = getUserInfo(item.user?.id || '', users);
1393
+ return item;
1394
+ });
1395
+ }
1396
+
1397
+ if (event.reaction) {
1398
+ event.reaction.user = getUserInfo(event.reaction.user?.id || '', users);
1399
+ event.message = channelState.removeReaction(event.reaction, event.message);
1400
+ }
1401
+ break;
1402
+ case 'member.joined':
1403
+ case 'notification.invite_accepted':
1404
+ if (event.member?.user_id) {
1405
+ const existUser = users.find((user) => user.id === event.member?.user_id);
1406
+
1407
+ if (!existUser) {
1408
+ const resUser = await this.getClient().queryUser(event.member?.user_id);
1409
+ users.push(resUser);
1410
+ }
1411
+
1412
+ const user = getUserInfo(event.member.user_id, users);
1413
+ event.member.user = user;
1414
+
1415
+ if (event.member.user_id === this.getClient().user?.id) {
1416
+ channelState.membership = event.member;
1417
+ this.state.membership = event.member;
1418
+ }
1419
+
1420
+ channelState.members[event.member.user_id] = event.member;
1421
+ channel.data = {
1422
+ ...channel.data,
1423
+ member_count: Number(channel.data?.member_count) + 1,
1424
+ members: channel.data?.members ? [...channel.data.members, event.member] : [event.member],
1425
+ } as ChannelAPIResponse<ErmisChatGenerics>['channel'];
1426
+ this.offlineMode = true;
1427
+ this.initialized = true;
1428
+ }
1429
+ break;
1430
+ case 'notification.invite_rejected':
1431
+ if (event.member?.user_id) {
1432
+ delete channelState.members[event.member.user_id];
1433
+
1434
+ // channel.data = {
1435
+ // ...channel.data,
1436
+ // member_count: Number(channel.data?.member_count) - 1,
1437
+ // members: channel.data?.members?.filter((m: any) => m.user_id !== event.member?.user_id) || [],
1438
+ // } as ChannelAPIResponse<ErmisChatGenerics>['channel'];
1439
+ }
1440
+ break;
1441
+ case 'notification.invite_messaging_skipped':
1442
+ if (event.member?.user_id) {
1443
+ const user = getUserInfo(event.member.user_id, users);
1444
+ event.member.user = user;
1445
+
1446
+ if (event.member.user_id === this.getClient().user?.id) {
1447
+ channelState.membership = event.member;
1448
+ this.state.membership = event.member;
1449
+ }
1450
+
1451
+ channelState.members[event.member.user_id] = event.member;
1452
+
1453
+ // this.offlineMode = true;
1454
+ // this.initialized = true;
1455
+ }
1456
+ break;
1457
+ case 'member.promoted':
1458
+ case 'member.demoted':
1459
+ case 'member.banned':
1460
+ case 'member.unbanned':
1461
+ case 'member.blocked':
1462
+ case 'member.unblocked':
1463
+ if (event.member?.user_id) {
1464
+ const user = getUserInfo(event.member.user_id, users);
1465
+ event.member.user = user;
1466
+ channelState.members[event.member.user_id] = event.member;
1467
+ if (event.member.user_id === this.getClient().user?.id) {
1468
+ channelState.membership = event.member;
1469
+ this.state.membership = event.member;
1470
+ }
1471
+ }
1472
+ break;
1473
+ case 'channel.pinned':
1474
+ if (channel.data) {
1475
+ channel.data.is_pinned = true;
1476
+ }
1477
+ break;
1478
+ case 'channel.unpinned':
1479
+ if (channel.data) {
1480
+ channel.data.is_pinned = false;
1481
+ }
1482
+ break;
1483
+ case 'channel.topic.disabled':
1484
+ if (channel.data) {
1485
+ channel.data.topics_enabled = false;
1486
+ }
1487
+ event.user = getUserInfo(event.user?.id || '', users);
1488
+ break;
1489
+ case 'channel.topic.enabled':
1490
+ if (channel.data) {
1491
+ channel.data.topics_enabled = true;
1492
+ }
1493
+ event.user = getUserInfo(event.user?.id || '', users);
1494
+ break;
1495
+ case 'channel.topic.created':
1496
+ const members = event.channel?.members || [];
1497
+ const enrichedMembers = enrichWithUserInfo(members, users);
1498
+
1499
+ const topicState: any = {
1500
+ channel: event.channel,
1501
+ members: enrichedMembers,
1502
+ messages: [],
1503
+ pinned_messages: [],
1504
+ };
1505
+ const topic = this.getClient().channel(event.channel_type || '', event.channel_id || '');
1506
+ topic.data = event.channel;
1507
+ topic._initializeState(topicState, 'latest');
1508
+ channelState.topics?.unshift(topic);
1509
+ break;
1510
+ case 'channel.topic.closed':
1511
+ if (channel.data) {
1512
+ channel.data.is_closed_topic = true;
1513
+ }
1514
+ event.user = getUserInfo(event.user?.id || '', users);
1515
+ break;
1516
+ case 'channel.topic.reopen':
1517
+ if (channel.data) {
1518
+ channel.data.is_closed_topic = false;
1519
+ }
1520
+ event.user = getUserInfo(event.user?.id || '', users);
1521
+ break;
1522
+ case 'channel.topic.updated':
1523
+ if (channel.data) {
1524
+ channel.data.name = event.channel?.name;
1525
+ channel.data.image = event.channel?.image;
1526
+ channel.data.description = event.channel?.description;
1527
+ }
1528
+
1529
+ event.user = getUserInfo(event.user?.id || '', users);
1530
+ break;
1531
+ default:
1532
+ }
1533
+
1534
+ // any event can send over the online count
1535
+ if (event.watcher_count !== undefined) {
1536
+ channel.state.watcher_count = event.watcher_count;
1537
+ }
1538
+ }
1539
+
1540
+ _callChannelListeners = (event: Event<ErmisChatGenerics>) => {
1541
+ const channel = this;
1542
+ // gather and call the listeners
1543
+ const listeners = [];
1544
+ if (channel.listeners.all) {
1545
+ listeners.push(...channel.listeners.all);
1546
+ }
1547
+ if (channel.listeners[event.type]) {
1548
+ listeners.push(...channel.listeners[event.type]);
1549
+ }
1550
+
1551
+ // call the event and send it to the listeners
1552
+ for (const listener of listeners) {
1553
+ if (typeof listener !== 'string') {
1554
+ listener(event);
1555
+ }
1556
+ }
1557
+ };
1558
+
1559
+ _channelURL = () => {
1560
+ if (!this.id) {
1561
+ throw new Error('channel id is not defined');
1562
+ }
1563
+ return `${this.getClient().baseURL}/channels/${this.type}/${this.id}`;
1564
+ };
1565
+
1566
+ _checkInitialized() {
1567
+ if (!this.initialized && !this.offlineMode) {
1568
+ throw Error(
1569
+ `Channel ${this.cid} hasn't been initialized yet. Make sure to call .watch() and wait for it to resolve`,
1570
+ );
1571
+ }
1572
+ }
1573
+
1574
+ // eslint-disable-next-line sonarjs/cognitive-complexity
1575
+ _initializeState(
1576
+ state: ChannelAPIResponse<ErmisChatGenerics>,
1577
+ messageSetToAddToIfDoesNotExist: MessageSetType = 'latest',
1578
+ updateUserIds?: (id: string) => void,
1579
+ ) {
1580
+ const { state: clientState, user, userID } = this.getClient();
1581
+ // add the Users
1582
+ if (state.channel.members) {
1583
+ for (const member of state.channel.members) {
1584
+ if (member.user) {
1585
+ if (updateUserIds) {
1586
+ updateUserIds(member.user.id);
1587
+ }
1588
+ clientState.updateUserReference(member.user, this.cid);
1589
+ }
1590
+ }
1591
+ }
1592
+
1593
+ this.state.membership = state.membership || {};
1594
+
1595
+ // Remove duplicate messages by ID
1596
+ const map = new Map();
1597
+ const uniqueMessages = [];
1598
+
1599
+ if (!state.messages) {
1600
+ state.messages = [];
1601
+ }
1602
+ for (const msg of state.messages) {
1603
+ if (!map.has(msg.id)) {
1604
+ map.set(msg.id, true);
1605
+ uniqueMessages.push(msg);
1606
+ }
1607
+ }
1608
+
1609
+ if (this.state.pinnedMessages) {
1610
+ this.state.pinnedMessages = [];
1611
+ }
1612
+ this.state.addPinnedMessages(state.pinned_messages || []);
1613
+
1614
+ const messages = uniqueMessages || [];
1615
+ if (!this.state.messages) {
1616
+ this.state.initMessages();
1617
+ }
1618
+ const { messageSet } = this.state.addMessagesSorted(messages, false, true, true, messageSetToAddToIfDoesNotExist);
1619
+
1620
+ if (state.watcher_count !== undefined) {
1621
+ this.state.watcher_count = state.watcher_count;
1622
+ }
1623
+ // NOTE: we don't send the watchers with the channel data anymore
1624
+ // // convert the arrays into objects for easier syncing...
1625
+ if (state.watchers) {
1626
+ for (const watcher of state.watchers) {
1627
+ if (watcher) {
1628
+ clientState.updateUserReference(watcher, this.cid);
1629
+ this.state.watchers[watcher.id] = watcher;
1630
+ }
1631
+ }
1632
+ }
1633
+
1634
+ // initialize read state to last message or current time if the channel is empty
1635
+ // if the user is a member, this value will be overwritten later on otherwise this ensures
1636
+ // that everything up to this point is not marked as unread
1637
+ if (userID != null) {
1638
+ const last_read = this.state.last_message_at || new Date();
1639
+ if (user) {
1640
+ this.state.read[user.id] = {
1641
+ user,
1642
+ last_read,
1643
+ unread_messages: 0,
1644
+ };
1645
+ }
1646
+ }
1647
+
1648
+ // apply read state if part of the state
1649
+ if (state.read) {
1650
+ for (const read of state.read) {
1651
+ this.state.read[read.user.id] = {
1652
+ last_read: new Date(read.last_read),
1653
+ last_read_message_id: read.last_read_message_id,
1654
+ unread_messages: read.unread_messages ?? 0,
1655
+ user: read.user,
1656
+ last_send: read.last_send,
1657
+ };
1658
+
1659
+ if (read.user.id === user?.id) {
1660
+ this.state.unreadCount = this.state.read[read.user.id].unread_messages;
1661
+ }
1662
+ }
1663
+ }
1664
+
1665
+ if (state.channel.members) {
1666
+ this.state.members = state.channel.members.reduce((acc, member) => {
1667
+ if (member.user) {
1668
+ acc[member.user.id] = member;
1669
+ }
1670
+ return acc;
1671
+ }, {} as ChannelState<ErmisChatGenerics>['members']);
1672
+ }
1673
+
1674
+ // Process topics for team channels
1675
+ if (state.channel.type === 'team' && state.channel.topics_enabled && state.topics) {
1676
+ const users = Object.values(this.getClient().state.users);
1677
+ this._processTopics(state.topics, users);
1678
+ }
1679
+
1680
+ return {
1681
+ messageSet,
1682
+ };
1683
+ }
1684
+
1685
+ _extendEventWithOwnReactions(event: Event<ErmisChatGenerics>) {
1686
+ if (!event.message) {
1687
+ return;
1688
+ }
1689
+ const message = this.state.findMessage(event.message.id, event.message.parent_id);
1690
+ if (message) {
1691
+ event.message.own_reactions = message.own_reactions;
1692
+ }
1693
+ }
1694
+
1695
+ _disconnect() {
1696
+ this._client.logger('info', `channel:disconnect() - Disconnecting the channel ${this.cid}`, {
1697
+ tags: ['connection', 'channel'],
1698
+ channel: this,
1699
+ });
1700
+
1701
+ this.disconnected = true;
1702
+ this.state.setIsUpToDate(false);
1703
+ }
1704
+ }