@ermis-network/ermis-chat-sdk 1.0.9 → 2.0.1

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