@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/client.ts ADDED
@@ -0,0 +1,1343 @@
1
+ /* eslint no-unused-vars: "off" */
2
+ /* global process */
3
+
4
+ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
5
+ import https from 'https';
6
+ import WebSocket from 'isomorphic-ws';
7
+
8
+ import { Channel } from './channel';
9
+ import { ClientState } from './client_state';
10
+ import { StableWSConnection } from './connection';
11
+
12
+ import { TokenManager } from './token_manager';
13
+
14
+ import { isErrorResponse } from './errors';
15
+ import { EventSourcePolyfill } from 'event-source-polyfill';
16
+ import {
17
+ addFileToFormData,
18
+ axiosParamsSerializer,
19
+ enrichWithUserInfo,
20
+ ensureMembersUserInfoLoaded,
21
+ getDirectChannelImage,
22
+ getDirectChannelName,
23
+ getLatestCreatedAt,
24
+ isFunction,
25
+ randomId,
26
+ } from './utils';
27
+
28
+ import {
29
+ APIErrorResponse,
30
+ APIResponse,
31
+ ChannelAPIResponse,
32
+ ChannelData,
33
+ ChannelFilters,
34
+ ChannelSort,
35
+ ChannelStateOptions,
36
+ ConnectAPIResponse,
37
+ DefaultGenerics,
38
+ ErrorFromResponse,
39
+ Event,
40
+ EventHandler,
41
+ ExtendableGenerics,
42
+ Logger,
43
+ QueryChannelsAPIResponse,
44
+ SendFileAPIResponse,
45
+ ErmisChatOptions,
46
+ UserResponse,
47
+ ContactResponse,
48
+ UsersResponse,
49
+ ContactResult,
50
+ Contact,
51
+ } from './types';
52
+
53
+ function isString(x: unknown): x is string {
54
+ return typeof x === 'string' || x instanceof String;
55
+ }
56
+ export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
57
+ private static _instance?: unknown | ErmisChat; // type is undefined|ErmisChat, unknown is due to TS limitations with statics
58
+
59
+ activeChannels: {
60
+ [key: string]: Channel<ErmisChatGenerics>;
61
+ };
62
+ axiosInstance: AxiosInstance;
63
+ baseURL?: string;
64
+ userBaseURL?: string;
65
+ browser: boolean;
66
+ cleaningIntervalRef?: NodeJS.Timeout;
67
+ clientID?: string;
68
+ apiKey: string;
69
+ projectId: string;
70
+ listeners: Record<string, Array<(event: Event<ErmisChatGenerics>) => void>>;
71
+ logger: Logger;
72
+ recoverStateOnReconnect?: boolean;
73
+ node: boolean;
74
+ options: ErmisChatOptions;
75
+ setUserPromise: ConnectAPIResponse<ErmisChatGenerics> | null;
76
+ state: ClientState<ErmisChatGenerics>;
77
+ tokenManager: TokenManager<ErmisChatGenerics>;
78
+ user?: UserResponse<ErmisChatGenerics>;
79
+ userAgent?: string;
80
+ userID?: string;
81
+ wsBaseURL?: string;
82
+ wsConnection: StableWSConnection<ErmisChatGenerics> | null;
83
+ wsPromise: ConnectAPIResponse<ErmisChatGenerics> | null;
84
+ consecutiveFailures: number;
85
+ defaultWSTimeout: number;
86
+
87
+ private eventSource: EventSourcePolyfill | null = null;
88
+
89
+ constructor(apiKey: string, projectId: string, baseURL: string, options?: ErmisChatOptions) {
90
+ this.apiKey = apiKey;
91
+ this.projectId = projectId;
92
+ this.listeners = {};
93
+ this.state = new ClientState<ErmisChatGenerics>();
94
+
95
+ const inputOptions = options || {};
96
+
97
+ this.browser = typeof inputOptions.browser !== 'undefined' ? inputOptions.browser : typeof window !== 'undefined';
98
+ this.node = !this.browser;
99
+
100
+ this.options = {
101
+ withCredentials: false,
102
+ warmUp: false,
103
+ recoverStateOnReconnect: true,
104
+ ...inputOptions,
105
+ };
106
+
107
+ if (this.node && !this.options.httpsAgent) {
108
+ this.options.httpsAgent = new https.Agent({
109
+ keepAlive: true,
110
+ keepAliveMsecs: 3000,
111
+ });
112
+ }
113
+
114
+ this.axiosInstance = axios.create(this.options);
115
+
116
+ this.setBaseURL(baseURL);
117
+
118
+ // WS connection is initialized when setUser is called
119
+ this.wsConnection = null;
120
+ this.wsPromise = null;
121
+ this.setUserPromise = null;
122
+ // keeps a reference to all the channels that are in use
123
+ this.activeChannels = {};
124
+
125
+ this.tokenManager = new TokenManager();
126
+ this.consecutiveFailures = 0;
127
+ this.defaultWSTimeout = 15000;
128
+
129
+ this.axiosInstance.defaults.paramsSerializer = axiosParamsSerializer;
130
+
131
+ this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null;
132
+ this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
133
+ }
134
+
135
+ public static getInstance<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics>(
136
+ key: string,
137
+ projectId: string,
138
+ baseURL: string,
139
+ options?: ErmisChatOptions,
140
+ ): ErmisChat<ErmisChatGenerics> {
141
+ if (!ErmisChat._instance) {
142
+ ErmisChat._instance = new ErmisChat<ErmisChatGenerics>(key, projectId, baseURL, options);
143
+ }
144
+
145
+ return ErmisChat._instance as ErmisChat<ErmisChatGenerics>;
146
+ }
147
+
148
+ async refreshNewToken(refresh_token: string) {
149
+ return await this.post<APIResponse>(this.userBaseURL + '/refresh_token', { refresh_token });
150
+ }
151
+
152
+ getAuthType() {
153
+ return 'jwt';
154
+ }
155
+
156
+ setBaseURL(baseURL: string) {
157
+ this.baseURL = baseURL;
158
+ this.userBaseURL = this.options.userBaseURL || baseURL + '/uss/v1';
159
+ this.wsBaseURL = this.baseURL.replace('http', 'ws').replace(':3030', ':8800');
160
+ }
161
+
162
+ async getExternalAuthToken(user: UserResponse<ErmisChatGenerics>, token: string | null) {
163
+ const params: any = { apikey: this.apiKey, name: user.name };
164
+ if (user.avatar) {
165
+ params.avatar = user.avatar;
166
+ }
167
+ const url = this.userBaseURL + '/get_token/external_auth';
168
+ const query = new URLSearchParams(params).toString();
169
+ const headers: Record<string, string> = {
170
+ 'Content-Type': 'application/json',
171
+ };
172
+ if (token) {
173
+ const tokenStr = typeof token === 'string' && token.startsWith('Bearer ') ? token : `Bearer ${token}`;
174
+ headers['Authorization'] = tokenStr;
175
+ }
176
+ const response = await fetch(`${url}?${query}`, {
177
+ method: 'GET',
178
+ headers,
179
+ });
180
+ if (!response.ok) {
181
+ let errorMsg = '';
182
+ try {
183
+ const errorData = await response.json();
184
+ errorMsg = errorData.message || JSON.stringify(errorData);
185
+ } catch {
186
+ errorMsg = await response.text();
187
+ }
188
+ throw new Error(errorMsg);
189
+ }
190
+ return await response.json();
191
+ }
192
+
193
+ connectUser = async (
194
+ user: UserResponse<ErmisChatGenerics>,
195
+ userTokenOrProvider: string | null,
196
+ extenal_auth?: boolean, // pass true if you are using external auth
197
+ ) => {
198
+ this.logger('info', 'client:connectUser() - started', {
199
+ tags: ['connection', 'client'],
200
+ });
201
+ if (!user.id) {
202
+ throw new Error('The "id" field on the user is missing');
203
+ }
204
+
205
+ // If external auth is enabled, get the token from the server
206
+ if (extenal_auth) {
207
+ const external_auth_token = await this.getExternalAuthToken(user, userTokenOrProvider);
208
+
209
+ userTokenOrProvider = external_auth_token.token;
210
+ user.id = external_auth_token.user_id;
211
+ }
212
+
213
+ /**
214
+ * Calling connectUser multiple times is potentially the result of a bad integration, however,
215
+ * If the user id remains the same we don't throw error
216
+ */
217
+ if (this.userID === user.id && this.setUserPromise) {
218
+ console.warn(
219
+ 'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.',
220
+ );
221
+ return this.setUserPromise;
222
+ }
223
+
224
+ if (this.userID) {
225
+ throw new Error(
226
+ 'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.',
227
+ );
228
+ }
229
+
230
+ if (this.node && !this.options.allowServerSideConnect) {
231
+ console.warn(
232
+ 'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.',
233
+ );
234
+ }
235
+
236
+ // we generate the client id client side
237
+ this.userID = user.id;
238
+
239
+ const setTokenPromise = this._setToken(user, userTokenOrProvider);
240
+ this._setUser(user);
241
+ this.state.updateUser({ id: user.id, name: user?.name || user.id, avatar: user?.avatar || '' });
242
+
243
+ const wsPromise = this.openConnection();
244
+
245
+ this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
246
+ (result) => result[1], // We only return connection promise;
247
+ );
248
+
249
+ try {
250
+ const result = await this.setUserPromise;
251
+ // Call SSE after successful connect
252
+ await this.connectToSSE();
253
+ return result;
254
+ } catch (err) {
255
+ this.disconnectUser();
256
+ throw err;
257
+ }
258
+ };
259
+
260
+ setUser = this.connectUser;
261
+
262
+ _setToken = (user: UserResponse<ErmisChatGenerics>, userTokenOrProvider: string | null) =>
263
+ this.tokenManager.setTokenOrProvider(userTokenOrProvider, user);
264
+
265
+ _setUser(user: UserResponse<ErmisChatGenerics>) {
266
+ this.user = { ...user };
267
+ this.userID = user.id;
268
+ }
269
+
270
+ closeConnection = async (timeout?: number) => {
271
+ if (this.cleaningIntervalRef != null) {
272
+ clearInterval(this.cleaningIntervalRef);
273
+ this.cleaningIntervalRef = undefined;
274
+ }
275
+
276
+ await this.wsConnection?.disconnect(timeout);
277
+ return Promise.resolve();
278
+ };
279
+
280
+ openConnection = async () => {
281
+ if (!this.userID) {
282
+ throw Error('User is not set on client, use client.connectUser instead');
283
+ }
284
+
285
+ if (this.wsConnection?.isConnecting && this.wsPromise) {
286
+ this.logger('info', 'client:openConnection() - connection already in progress', {
287
+ tags: ['connection', 'client'],
288
+ });
289
+ return this.wsPromise;
290
+ }
291
+
292
+ if (this.wsConnection?.isHealthy) {
293
+ this.logger('info', 'client:openConnection() - openConnection called twice, healthy connection already exists', {
294
+ tags: ['connection', 'client'],
295
+ });
296
+
297
+ return Promise.resolve();
298
+ }
299
+
300
+ this.clientID = `${this.userID}--${randomId()}`;
301
+ this.wsPromise = this.connect();
302
+ this._startCleaning();
303
+ return this.wsPromise;
304
+ };
305
+
306
+ _setupConnection = this.openConnection;
307
+
308
+ disconnectUser = async (timeout?: number) => {
309
+ this.logger('info', 'client:disconnect() - Disconnecting the client', {
310
+ tags: ['connection', 'client'],
311
+ });
312
+
313
+ // remove the user specific fields
314
+ delete this.user;
315
+ delete this.userID;
316
+
317
+ const closePromise = this.closeConnection(timeout);
318
+
319
+ for (const channel of Object.values(this.activeChannels)) {
320
+ channel._disconnect();
321
+ }
322
+ // ensure we no longer return inactive channels
323
+ this.activeChannels = {};
324
+ // reset client state
325
+ this.state = new ClientState();
326
+ // reset token manager
327
+ setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls
328
+
329
+ // close the WS connection
330
+ return closePromise;
331
+ };
332
+
333
+ disconnect = this.disconnectUser;
334
+
335
+ on(callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
336
+ on(eventType: string, callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
337
+ on(
338
+ callbackOrString: EventHandler<ErmisChatGenerics> | string,
339
+ callbackOrNothing?: EventHandler<ErmisChatGenerics>,
340
+ ): { unsubscribe: () => void } {
341
+ const key = callbackOrNothing ? (callbackOrString as string) : 'all';
342
+ const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
343
+ if (!(key in this.listeners)) {
344
+ this.listeners[key] = [];
345
+ }
346
+ this.logger('info', `Attaching listener for ${key} event`, {
347
+ tags: ['event', 'client'],
348
+ });
349
+ this.listeners[key].push(callback);
350
+ return {
351
+ unsubscribe: () => {
352
+ this.logger('info', `Removing listener for ${key} event`, {
353
+ tags: ['event', 'client'],
354
+ });
355
+ this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
356
+ },
357
+ };
358
+ }
359
+
360
+ off(callback: EventHandler<ErmisChatGenerics>): void;
361
+ off(eventType: string, callback: EventHandler<ErmisChatGenerics>): void;
362
+ off(callbackOrString: EventHandler<ErmisChatGenerics> | string, callbackOrNothing?: EventHandler<ErmisChatGenerics>) {
363
+ const key = callbackOrNothing ? (callbackOrString as string) : 'all';
364
+ const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
365
+ if (!(key in this.listeners)) {
366
+ this.listeners[key] = [];
367
+ }
368
+
369
+ this.logger('info', `Removing listener for ${key} event`, {
370
+ tags: ['event', 'client'],
371
+ });
372
+ this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
373
+ }
374
+
375
+ _logApiRequest(
376
+ type: string,
377
+ url: string,
378
+ data: unknown,
379
+ config: AxiosRequestConfig & {
380
+ config?: AxiosRequestConfig & { maxBodyLength?: number };
381
+ },
382
+ ) {
383
+ this.logger(
384
+ 'info',
385
+ `client: ${type} - Request - ${url}- ${JSON.stringify(data)} - ${JSON.stringify(config.params)}`,
386
+ {
387
+ tags: ['api', 'api_request', 'client'],
388
+ url,
389
+ payload: data,
390
+ config,
391
+ },
392
+ );
393
+ }
394
+
395
+ _logApiResponse<T>(type: string, url: string, response: AxiosResponse<T>) {
396
+ this.logger('info', `client:${type} - Response - url: ${url} > status ${response.status}`, {
397
+ tags: ['api', 'api_response', 'client'],
398
+ url,
399
+ response,
400
+ });
401
+ }
402
+
403
+ _logApiError(type: string, url: string, error: unknown, options: unknown) {
404
+ this.logger(
405
+ 'error',
406
+ `client:${type} - Error: ${JSON.stringify(error)} - url: ${url} - options: ${JSON.stringify(options)}`,
407
+ {
408
+ tags: ['api', 'api_response', 'client'],
409
+ url,
410
+ error,
411
+ },
412
+ );
413
+ }
414
+
415
+ doAxiosRequest = async <T>(
416
+ type: string,
417
+ url: string,
418
+ data?: unknown,
419
+ options: AxiosRequestConfig & {
420
+ config?: AxiosRequestConfig & { maxBodyLength?: number };
421
+ } = {},
422
+ ): Promise<T> => {
423
+ await this.tokenManager.tokenReady();
424
+
425
+ const requestConfig = this._enrichAxiosOptions(options);
426
+
427
+ try {
428
+ let response: AxiosResponse<T>;
429
+ this._logApiRequest(type, url, data, requestConfig);
430
+ switch (type) {
431
+ case 'get':
432
+ response = await this.axiosInstance.get(url, requestConfig);
433
+ break;
434
+ case 'delete':
435
+ response = await this.axiosInstance.delete(url, requestConfig);
436
+ break;
437
+ case 'post':
438
+ response = await this.axiosInstance.post(url, data, requestConfig);
439
+ break;
440
+ case 'postForm':
441
+ response = await this.axiosInstance.postForm(url, data, requestConfig);
442
+ break;
443
+ case 'put':
444
+ response = await this.axiosInstance.put(url, data, requestConfig);
445
+ break;
446
+ case 'patch':
447
+ response = await this.axiosInstance.patch(url, data, requestConfig);
448
+ break;
449
+ case 'options':
450
+ response = await this.axiosInstance.options(url, requestConfig);
451
+ break;
452
+ default:
453
+ throw new Error('Invalid request type');
454
+ }
455
+ this._logApiResponse<T>(type, url, response);
456
+ this.consecutiveFailures = 0;
457
+ return this.handleResponse(response);
458
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
459
+ } catch (e: any /**TODO: generalize error types */) {
460
+ e.client_request_id = requestConfig.headers?.['x-client-request-id'];
461
+ this._logApiError(type, url, e, options);
462
+ this.consecutiveFailures += 1;
463
+ if (e.response) {
464
+ return this.handleResponse(e.response);
465
+ } else {
466
+ throw e as AxiosError<APIErrorResponse>;
467
+ }
468
+ }
469
+ };
470
+
471
+ get<T>(url: string, params?: AxiosRequestConfig['params']) {
472
+ return this.doAxiosRequest<T>('get', url, null, { params });
473
+ }
474
+
475
+ put<T>(url: string, data?: unknown) {
476
+ return this.doAxiosRequest<T>('put', url, data);
477
+ }
478
+
479
+ post<T>(url: string, data?: unknown, params?: AxiosRequestConfig['params']) {
480
+ return this.doAxiosRequest<T>('post', url, data, { params });
481
+ }
482
+
483
+ patch<T>(url: string, data?: unknown) {
484
+ return this.doAxiosRequest<T>('patch', url, data);
485
+ }
486
+
487
+ delete<T>(url: string, params?: AxiosRequestConfig['params']) {
488
+ return this.doAxiosRequest<T>('delete', url, null, { params });
489
+ }
490
+
491
+ sendFile(
492
+ url: string,
493
+ uri: string | NodeJS.ReadableStream | Buffer | File,
494
+ name?: string,
495
+ contentType?: string,
496
+ user?: UserResponse<ErmisChatGenerics>,
497
+ ) {
498
+ const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
499
+ if (user != null) data.append('user', JSON.stringify(user));
500
+
501
+ return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, {
502
+ headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser
503
+ config: {
504
+ timeout: 0,
505
+ maxContentLength: Infinity,
506
+ maxBodyLength: Infinity,
507
+ },
508
+ });
509
+ }
510
+
511
+ errorFromResponse(response: AxiosResponse<APIErrorResponse>): ErrorFromResponse<APIErrorResponse> {
512
+ let err: ErrorFromResponse<APIErrorResponse>;
513
+ err = new ErrorFromResponse(`ErmisChat error HTTP code: ${response.status}`);
514
+ if (response.data && response.data.code) {
515
+ err = new Error(`ErmisChat error code ${response.data.code}: ${response.data.message}`);
516
+ err.code = response.data.code;
517
+ }
518
+ err.response = response;
519
+ err.status = response.status;
520
+ return err;
521
+ }
522
+
523
+ handleResponse<T>(response: AxiosResponse<T>) {
524
+ const data = response.data;
525
+ if (isErrorResponse(response)) {
526
+ throw this.errorFromResponse(response);
527
+ }
528
+ return data;
529
+ }
530
+
531
+ dispatchEvent = (event: Event<ErmisChatGenerics>) => {
532
+ if (!event.received_at) event.received_at = new Date();
533
+
534
+ // If the event is channel.created, handle it asynchronously
535
+ if (event.type === 'channel.created') {
536
+ this._handleChannelCreatedEvent(event).then(() => {
537
+ this._afterDispatchEvent(event);
538
+ });
539
+ } else {
540
+ const postListenerCallbacks = this._handleClientEvent(event);
541
+
542
+ // channel event handlers
543
+ const cid = event.cid;
544
+ const channel = cid ? this.activeChannels[cid] : undefined;
545
+ if (channel) {
546
+ channel._handleChannelEvent(event);
547
+ }
548
+
549
+ this._callClientListeners(event);
550
+
551
+ if (channel) {
552
+ channel._callChannelListeners(event);
553
+ }
554
+
555
+ postListenerCallbacks.forEach((c) => c());
556
+ }
557
+ };
558
+
559
+ _afterDispatchEvent(event: Event<ErmisChatGenerics>) {
560
+ const postListenerCallbacks = this._handleClientEvent(event);
561
+
562
+ const cid = event.cid;
563
+ const channel = cid ? this.activeChannels[cid] : undefined;
564
+ if (channel) {
565
+ channel._handleChannelEvent(event);
566
+ }
567
+
568
+ this._callClientListeners(event);
569
+
570
+ if (channel) {
571
+ channel._callChannelListeners(event);
572
+ }
573
+
574
+ postListenerCallbacks.forEach((c) => c());
575
+ }
576
+
577
+ private async _handleChannelCreatedEvent(event: Event<ErmisChatGenerics>) {
578
+ const members = event.channel?.members || [];
579
+ // Ensure all members' user info are loaded in state.users
580
+ await ensureMembersUserInfoLoaded(this, members);
581
+
582
+ // Get the latest users after updating
583
+ const updatedUsers = Object.values(this.state.users);
584
+
585
+ const enrichedMembers = enrichWithUserInfo(members, updatedUsers);
586
+ const channelName =
587
+ event.channel_type === 'messaging'
588
+ ? getDirectChannelName(enrichedMembers, this.userID || '')
589
+ : event.channel?.name;
590
+ const channel = {
591
+ ...event.channel,
592
+ members: enrichedMembers,
593
+ name: channelName,
594
+ };
595
+ const channelState: any = {
596
+ channel,
597
+ members: enrichedMembers,
598
+ messages: [],
599
+ pinned_messages: [],
600
+ };
601
+ const c = this.channel(event.channel_type || '', event.channel_id || '');
602
+ c.data = channel;
603
+ c._initializeState(channelState, 'latest');
604
+ }
605
+
606
+ handleEvent = (messageEvent: WebSocket.MessageEvent) => {
607
+ // dispatch the event to the channel listeners
608
+ const jsonString = messageEvent.data as string;
609
+ const event = JSON.parse(jsonString) as Event<ErmisChatGenerics>;
610
+ this.dispatchEvent(event);
611
+ };
612
+
613
+ _updateMemberWatcherReferences = (user: UserResponse<ErmisChatGenerics>) => {
614
+ const refMap = this.state.userChannelReferences[user.id] || {};
615
+ for (const channelID in refMap) {
616
+ const channel = this.activeChannels[channelID];
617
+ if (channel?.state) {
618
+ if (channel.state.members[user.id]) {
619
+ channel.state.members[user.id].user = user;
620
+ }
621
+ if (channel.state.watchers[user.id]) {
622
+ channel.state.watchers[user.id] = user;
623
+ }
624
+ if (channel.state.read[user.id]) {
625
+ channel.state.read[user.id].user = user;
626
+ }
627
+ }
628
+ }
629
+ };
630
+
631
+ _updateUserReferences = this._updateMemberWatcherReferences;
632
+
633
+ _updateUserMessageReferences = (user: UserResponse<ErmisChatGenerics>) => {
634
+ const refMap = this.state.userChannelReferences[user.id] || {};
635
+
636
+ for (const channelID in refMap) {
637
+ const channel = this.activeChannels[channelID];
638
+
639
+ if (!channel) continue;
640
+
641
+ const state = channel.state;
642
+
643
+ /** update the messages from this user. */
644
+ state?.updateUserMessages(user);
645
+ }
646
+ };
647
+
648
+ _deleteUserMessageReference = (user: UserResponse<ErmisChatGenerics>, hardDelete = false) => {
649
+ const refMap = this.state.userChannelReferences[user.id] || {};
650
+
651
+ for (const channelID in refMap) {
652
+ const channel = this.activeChannels[channelID];
653
+ const state = channel.state;
654
+
655
+ /** deleted the messages from this user. */
656
+ state?.deleteUserMessages(user, hardDelete);
657
+ }
658
+ };
659
+
660
+ _handleClientEvent(event: Event<ErmisChatGenerics>) {
661
+ const client = this;
662
+ const postListenerCallbacks = [];
663
+ this.logger('info', `client:_handleClientEvent - Received event of type { ${event.type} }`, {
664
+ tags: ['event', 'client'],
665
+ event,
666
+ });
667
+
668
+ if (event.type === 'health.check' && event.me) {
669
+ }
670
+
671
+ if ((event.type === 'channel.deleted' || event.type === 'notification.channel_deleted') && event.cid) {
672
+ client.state.deleteAllChannelReference(event.cid);
673
+ this.activeChannels[event.cid]?._disconnect();
674
+
675
+ postListenerCallbacks.push(() => {
676
+ if (!event.cid) return;
677
+
678
+ delete this.activeChannels[event.cid];
679
+ });
680
+
681
+ for (const channel of Object.values(this.activeChannels)) {
682
+ if (channel.type === 'team' && channel.state.topics?.some((t) => t.cid === event.cid)) {
683
+ // Remove the topic with matching cid from the topics array
684
+ channel.state.topics = channel.state.topics.filter((t) => t.cid !== event.cid);
685
+ }
686
+ }
687
+ }
688
+ if (event.type === 'notification.invite_rejected') {
689
+ if (event.member?.user_id === this.userID && event.cid) {
690
+ client.state.deleteAllChannelReference(event.cid);
691
+ this.activeChannels[event.cid]?._disconnect();
692
+
693
+ postListenerCallbacks.push(() => {
694
+ if (!event.cid) return;
695
+
696
+ delete this.activeChannels[event.cid];
697
+ });
698
+ }
699
+ }
700
+ if (event.type === 'notification.invite_accepted') {
701
+ //TODO handle channel list and invited channels here
702
+ }
703
+
704
+ if (event.type === 'member.added') {
705
+ if (event.member?.user_id === this.userID) {
706
+ const c = this.channel(event.channel_type || '', event.channel_id || '');
707
+ // Gọi watch để lấy đầy đủ thông tin channel từ server
708
+ c.watch().catch((err) => {
709
+ this.logger('error', 'Failed to watch channel after member.added', { err, event });
710
+ });
711
+ }
712
+ }
713
+
714
+ if (event.type === 'connection.recovered') {
715
+ postListenerCallbacks.push(() => {
716
+ // Auto-resend offline failed messages
717
+ Object.values(this.activeChannels).forEach((channel) => {
718
+ if (!channel.state?.messages) return;
719
+ const offlineFailedMsgs = channel.state.messages.filter(
720
+ (m) =>
721
+ m.status === 'failed_offline' &&
722
+ m.user?.id === this.userID &&
723
+ (!m.attachments || m.attachments.length === 0),
724
+ );
725
+ offlineFailedMsgs.forEach((msg) => {
726
+ if (msg.id) {
727
+ channel.retryMessage(msg.id).catch((err) => {
728
+ this.logger('error', `Failed to auto-resend offline message ${msg.id}`, {
729
+ tags: ['offline', 'retry'],
730
+ err,
731
+ });
732
+ });
733
+ }
734
+ });
735
+ });
736
+ });
737
+ }
738
+
739
+ return postListenerCallbacks;
740
+ }
741
+
742
+ _callClientListeners = (event: Event<ErmisChatGenerics>) => {
743
+ const client = this;
744
+ // gather and call the listeners
745
+ const listeners: Array<(event: Event<ErmisChatGenerics>) => void> = [];
746
+ if (client.listeners.all) {
747
+ listeners.push(...client.listeners.all);
748
+ }
749
+ if (client.listeners[event.type]) {
750
+ listeners.push(...client.listeners[event.type]);
751
+ }
752
+
753
+ // call the event and send it to the listeners
754
+ for (const listener of listeners) {
755
+ listener(event);
756
+ }
757
+ };
758
+
759
+ recoverState = async () => {
760
+ this.logger('info', 'client:recoverState() - Start of recoverState', {
761
+ tags: ['connection'],
762
+ });
763
+
764
+ const cids = Object.keys(this.activeChannels);
765
+ if (cids.length && this.recoverStateOnReconnect) {
766
+ this.logger('info', `client:recoverState() - Start the querying of ${cids.length} channels`, {
767
+ tags: ['connection', 'client'],
768
+ });
769
+
770
+ const filter: ChannelFilters = {
771
+ type: ['messaging', 'team'],
772
+ };
773
+ const sort: [] = [];
774
+ const options = {
775
+ message_limit: 25,
776
+ };
777
+
778
+ await this.queryChannels(filter, sort, options);
779
+
780
+ this.logger('info', 'client:recoverState() - Querying channels finished', { tags: ['connection', 'client'] });
781
+ this.dispatchEvent({
782
+ type: 'connection.recovered',
783
+ } as Event<ErmisChatGenerics>);
784
+ } else {
785
+ this.dispatchEvent({
786
+ type: 'connection.recovered',
787
+ } as Event<ErmisChatGenerics>);
788
+ }
789
+
790
+ this.wsPromise = Promise.resolve();
791
+ this.setUserPromise = Promise.resolve();
792
+ };
793
+
794
+ async connect() {
795
+ if (!this.userID || !this.user) {
796
+ throw Error('Call connectUser before starting the connection');
797
+ }
798
+ if (!this.wsBaseURL) {
799
+ throw Error('Websocket base url not set');
800
+ }
801
+ if (!this.clientID) {
802
+ throw Error('clientID is not set');
803
+ }
804
+
805
+ // if (!this.wsConnection && (this.options.warmUp || this.options.enableInsights)) {
806
+ // this._sayHi();
807
+ // }
808
+ // The StableWSConnection handles all the reconnection logic.
809
+ if (this.options.wsConnection && this.node) {
810
+ // Intentionally avoiding adding ts generics on wsConnection in options since its only useful for unit test purpose.
811
+ (this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>).setClient(this);
812
+ this.wsConnection = this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>;
813
+ } else {
814
+ this.wsConnection = new StableWSConnection<ErmisChatGenerics>({
815
+ client: this,
816
+ });
817
+ }
818
+
819
+ try {
820
+ return await this.wsConnection.connect(this.defaultWSTimeout);
821
+ } catch (err: any) {
822
+ throw err;
823
+ }
824
+ }
825
+ public async connectToSSE(onCallBack?: (data: any) => void): Promise<void> {
826
+ if (this.eventSource) {
827
+ this.logger('info', 'client:connectToSSE() - SSE connection already established', {});
828
+ return;
829
+ }
830
+ let token = this._getToken();
831
+
832
+ if (!token?.startsWith('Bearer ')) {
833
+ token = `Bearer ${token}`;
834
+ }
835
+ const headers = {
836
+ method: 'GET',
837
+ Authorization: token,
838
+ };
839
+ this.eventSource = new EventSourcePolyfill(this.userBaseURL + '/sse/subscribe', {
840
+ headers,
841
+ heartbeatTimeout: 60000,
842
+ });
843
+ this.eventSource.onopen = () => {
844
+ this.logger('info', 'client:connectToSSE() - SSE connection established', {});
845
+ };
846
+ this.eventSource.onmessage = (event) => {
847
+ const data = JSON.parse(event.data);
848
+
849
+ this.logger('info', `client:connectToSSE() - SSE message received event : ${JSON.stringify(data)}`, { event });
850
+
851
+ if (data.type === 'AccountUserChainProjects') {
852
+ let user: UserResponse = {
853
+ name: data.name,
854
+ id: data.id,
855
+ avatar: data.avatar,
856
+ about_me: data.about_me,
857
+ project_id: data.project_id,
858
+ };
859
+
860
+ if (this.user?.id === user.id) {
861
+ this.user = { ...this.user, ...user };
862
+ }
863
+
864
+ this.state.updateUser(user);
865
+
866
+ const userInfo = {
867
+ id: user.id,
868
+ name: user.name ? user.name : user.id,
869
+ avatar: user?.avatar || '',
870
+ };
871
+
872
+ this._updateMemberWatcherReferences(userInfo);
873
+ this._updateUserMessageReferences(userInfo);
874
+
875
+ Object.values(this.activeChannels).forEach((channel) => {
876
+ if (channel.data?.type === 'messaging' && Object.keys(channel.state.members).length === 2) {
877
+ const otherMember = Object.values(channel.state.members).find((member) => member.user?.id !== this.userID);
878
+ if (otherMember && otherMember.user?.id === user.id) {
879
+ // Cập nhật tên và avatar channel theo user vừa đổi thông tin
880
+ channel.data.name = user.name || user.id;
881
+ channel.data.image = user.avatar || '';
882
+ }
883
+ }
884
+ });
885
+
886
+ if (onCallBack) {
887
+ onCallBack(data);
888
+ }
889
+ }
890
+ };
891
+ this.eventSource.onerror = (event: any) => {
892
+ this.logger('error', `client:connectToSSE() - SSE connection error : ${JSON.stringify(event.data)} `, { event });
893
+ if (event.status === 401) {
894
+ this.logger('error', 'client:connectToSSE() - Unauthorized (401). Aborting the connection.', {});
895
+ this.disconnectFromSSE();
896
+ } else if (
897
+ this.eventSource?.readyState === EventSourcePolyfill.CLOSED ||
898
+ this.eventSource?.readyState === EventSourcePolyfill.CONNECTING
899
+ ) {
900
+ this.eventSource.close();
901
+ setTimeout(() => {
902
+ this.logger('info', 'client:connectToSSE() - Reconnecting to SSE', {});
903
+ this.connectToSSE(onCallBack);
904
+ }, 3000);
905
+ }
906
+ };
907
+ }
908
+ public async disconnectFromSSE(): Promise<void> {
909
+ if (this.eventSource) {
910
+ this.eventSource.close();
911
+ this.eventSource = null;
912
+ this.logger('info', 'client:disconnectFromSSE() - SSE connection closed', {});
913
+ } else {
914
+ this.logger('info', 'client:disconnectFromSSE() - SSE connection already closed', {});
915
+ }
916
+ }
917
+
918
+ async queryUsers(page_size?: string, page?: number): Promise<UsersResponse> {
919
+ const defaultOptions = {
920
+ presence: false,
921
+ };
922
+
923
+ // Make sure we wait for the connect promise if there is a pending one
924
+ await this.wsPromise;
925
+
926
+ let project_id = this.projectId;
927
+ // Return a list of users
928
+ const data = await this.get<UsersResponse>(this.userBaseURL + '/users', {
929
+ project_id,
930
+ page,
931
+ page_size,
932
+ });
933
+
934
+ this.state.updateUsers(data.data);
935
+
936
+ return data;
937
+ }
938
+
939
+ async queryUser(user_id: string): Promise<UserResponse<ErmisChatGenerics>> {
940
+ const project_id = this.projectId;
941
+
942
+ const userResponse = await this.get<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/' + user_id, {
943
+ project_id,
944
+ });
945
+
946
+ this.state.updateUser(userResponse);
947
+ return userResponse;
948
+ }
949
+
950
+ async getBatchUsers(users: string[], page?: number, page_size?: number) {
951
+ let project_id = this.projectId;
952
+
953
+ const usersRepsonse = await this.post<UsersResponse>(
954
+ this.userBaseURL + '/users/batch?page=1&page_size=10000',
955
+ { users, project_id },
956
+ { page, page_size },
957
+ );
958
+
959
+ this.state.updateUsers(usersRepsonse.data);
960
+
961
+ return usersRepsonse.data || [];
962
+ }
963
+
964
+ async searchUsers(page: number, page_size: number, name?: string): Promise<UsersResponse> {
965
+ let project_id = this.projectId;
966
+
967
+ const usersResponse = await this.post<UsersResponse>(this.userBaseURL + '/users/search', undefined, {
968
+ page,
969
+ page_size,
970
+ name,
971
+ project_id,
972
+ });
973
+
974
+ // this.state.updateUsers(usersResponse.data);
975
+
976
+ return usersResponse;
977
+ }
978
+
979
+ async queryContacts(): Promise<ContactResult> {
980
+ let project_id = this.projectId;
981
+ const contactResponse = await this.post<ContactResponse>(this.baseURL + '/contacts/list', { project_id });
982
+ const userIds = contactResponse.project_id_user_ids[project_id];
983
+ const contact_users: UserResponse<ErmisChatGenerics>[] = [];
984
+ const block_users: UserResponse<ErmisChatGenerics>[] = [];
985
+
986
+ userIds.forEach((contact: Contact) => {
987
+ const userID = contact.other_id;
988
+ const state_user = this.state.users[userID];
989
+ const user = state_user ? state_user : { id: userID };
990
+ switch (contact.relation_status) {
991
+ case 'blocked':
992
+ block_users.push(user);
993
+ break;
994
+ case 'normal':
995
+ contact_users.push(user);
996
+ break;
997
+ default:
998
+ }
999
+ });
1000
+
1001
+ return {
1002
+ contact_users,
1003
+ block_users,
1004
+ };
1005
+ }
1006
+
1007
+ _updateProjectID(project_id: string) {
1008
+ this.projectId = project_id;
1009
+ }
1010
+
1011
+ async uploadFile(file: File) {
1012
+ const formData = new FormData();
1013
+ formData.append('avatar', file);
1014
+ let response = await this.post<{ avatar: string }>(this.userBaseURL + '/users/upload', formData, {
1015
+ headers: {
1016
+ 'Content-Type': 'multipart/form-data',
1017
+ },
1018
+ });
1019
+ if (this.user) {
1020
+ this.user.avatar = response.avatar;
1021
+ const new_user = { ...this.user, avatar: response.avatar };
1022
+ this.state.updateUser(new_user);
1023
+ }
1024
+
1025
+ return response;
1026
+ }
1027
+ async updateProfile(name: string, about_me: string) {
1028
+ let body = {
1029
+ name,
1030
+ about_me,
1031
+ };
1032
+ let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', body);
1033
+ this.user = response;
1034
+ this.state.updateUser(response);
1035
+ return response;
1036
+ }
1037
+
1038
+ async queryChannels(
1039
+ filterConditions: ChannelFilters,
1040
+ sort: ChannelSort = [],
1041
+ options: { message_limit?: number } = {},
1042
+ stateOptions: ChannelStateOptions = {},
1043
+ ) {
1044
+ // Make sure we wait for the connect promise if there is a pending one
1045
+ await this.wsPromise;
1046
+
1047
+ let project_id = this.projectId;
1048
+
1049
+ // Return a list of channels
1050
+ const payload = {
1051
+ filter_conditions: { ...filterConditions, project_id },
1052
+ sort,
1053
+ ...options,
1054
+ };
1055
+
1056
+ const data = await this.post<QueryChannelsAPIResponse<ErmisChatGenerics>>(this.baseURL + '/channels', payload);
1057
+
1058
+ // Sort channels by latest message created_at (including topics if present)
1059
+ data.channels.sort((a, b) => {
1060
+ // Get latest message created_at in channel a
1061
+ let aLatest = getLatestCreatedAt(a.messages);
1062
+
1063
+ // If channel a has topics, check messages in topics
1064
+ if (a.channel.type === 'team' && Array.isArray(a.topics)) {
1065
+ for (const topic of a.topics) {
1066
+ aLatest = Math.max(aLatest, getLatestCreatedAt(topic.messages));
1067
+ }
1068
+ }
1069
+
1070
+ // Get latest message created_at in channel b
1071
+ let bLatest = getLatestCreatedAt(b.messages);
1072
+
1073
+ // If channel b has topics, check messages in topics
1074
+ if (b.channel.type === 'team' && Array.isArray(b.topics)) {
1075
+ for (const topic of b.topics) {
1076
+ bLatest = Math.max(bLatest, getLatestCreatedAt(topic.messages));
1077
+ }
1078
+ }
1079
+
1080
+ // Descending order (newest first)
1081
+ return bLatest - aLatest;
1082
+ });
1083
+
1084
+ const memberIds =
1085
+ Array.from(
1086
+ new Set(data.channels.flatMap((c) => (c.channel.members || []).map((member: any) => member.user.id))),
1087
+ ) || [];
1088
+
1089
+ const membersInfo = filterConditions.parent_cid
1090
+ ? Object.values(this.state.users)
1091
+ : await this.getBatchUsers(memberIds);
1092
+ data.channels.forEach((c) => {
1093
+ c.channel.members = enrichWithUserInfo(c.channel.members, membersInfo);
1094
+ c.messages = enrichWithUserInfo(c.messages, membersInfo);
1095
+ c.read = enrichWithUserInfo(c.read || [], membersInfo);
1096
+ c.channel.name =
1097
+ c.channel.type === 'messaging' ? getDirectChannelName(c.channel.members, this.userID || '') : c.channel.name;
1098
+ c.channel.image =
1099
+ c.channel.type === 'messaging' ? getDirectChannelImage(c.channel.members, this.userID || '') : c.channel.image;
1100
+
1101
+ if (c.channel.type === 'team' && Array.isArray(c.topics)) {
1102
+ c.topics.sort((a, b) => {
1103
+ const aLatest = getLatestCreatedAt(a.messages);
1104
+ const bLatest = getLatestCreatedAt(b.messages);
1105
+ return bLatest - aLatest;
1106
+ });
1107
+ }
1108
+
1109
+ if (c.pinned_messages) {
1110
+ c.pinned_messages = enrichWithUserInfo(c.pinned_messages || [], membersInfo);
1111
+ }
1112
+ });
1113
+
1114
+ const { channels, userIds } = this.hydrateChannels(data.channels, stateOptions);
1115
+
1116
+ // if (userIds.length > 0) {
1117
+ // await this.getBatchUsers(userIds);
1118
+ // }
1119
+
1120
+ console.log('---channels---', channels);
1121
+
1122
+ return channels;
1123
+ }
1124
+
1125
+ hydrateChannels(
1126
+ channelsFromApi: ChannelAPIResponse<ErmisChatGenerics>[] = [],
1127
+ stateOptions: ChannelStateOptions = {},
1128
+ ) {
1129
+ const { skipInitialization, offlineMode = false } = stateOptions;
1130
+
1131
+ const channels: Channel<ErmisChatGenerics>[] = [];
1132
+ const userIds: string[] = [];
1133
+ for (const channelState of channelsFromApi) {
1134
+ const c = this.channel(channelState.channel.type, channelState.channel.id);
1135
+ c.data = { ...channelState.channel, is_pinned: channelState.is_pinned || false };
1136
+ c.offlineMode = offlineMode;
1137
+ c.initialized = !offlineMode;
1138
+
1139
+ if (skipInitialization === undefined) {
1140
+ c._initializeState(channelState, 'latest', (id) => {
1141
+ if (!userIds.includes(id)) {
1142
+ userIds.push(id);
1143
+ }
1144
+ });
1145
+ } else if (!skipInitialization.includes(channelState.channel.id)) {
1146
+ c.state.clearMessages();
1147
+ c._initializeState(channelState, 'latest', (id) => {
1148
+ if (!userIds.includes(id)) {
1149
+ userIds.push(id);
1150
+ }
1151
+ });
1152
+ }
1153
+
1154
+ channels.push(c);
1155
+ }
1156
+
1157
+ // const sortedChannels = channels.sort((a: any, b: any) => {
1158
+ // const aTime = a.state.last_message_at
1159
+ // ? new Date(a.state.last_message_at).getTime()
1160
+ // : a.data.created_at
1161
+ // ? new Date(a.data.created_at).getTime()
1162
+ // : 0;
1163
+ // const bTime = b.state.last_message_at
1164
+ // ? new Date(b.state.last_message_at).getTime()
1165
+ // : b.data.created_at
1166
+ // ? new Date(b.data.created_at).getTime()
1167
+ // : 0;
1168
+ // return bTime - aTime; // Descending order
1169
+ // });
1170
+
1171
+ // ensure we have the users for all the channels we just added
1172
+
1173
+ return { channels, userIds };
1174
+ }
1175
+
1176
+ async searchPublicChannel(search_term: string, offset = 0, limit = 25) {
1177
+ let project_id = this.projectId;
1178
+
1179
+ return await this.post<APIResponse>(this.baseURL + `/channels/public/search`, {
1180
+ project_id,
1181
+ search_term,
1182
+ limit: limit,
1183
+ offset: offset,
1184
+ });
1185
+ }
1186
+
1187
+ async pinChannel(channelType: string, channelId: string) {
1188
+ return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/pin`);
1189
+ }
1190
+
1191
+ async unpinChannel(channelType: string, channelId: string) {
1192
+ return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/unpin`);
1193
+ }
1194
+
1195
+ channel(
1196
+ channelType: string,
1197
+ channelID: string,
1198
+ custom: ChannelData<ErmisChatGenerics> = {} as ChannelData<ErmisChatGenerics>,
1199
+ ) {
1200
+ if (!this.userID) {
1201
+ throw Error('Call connectUser before creating a channel');
1202
+ }
1203
+
1204
+ if (~channelType.indexOf(':')) {
1205
+ throw Error(`Invalid channel group ${channelType}, can't contain the : character`);
1206
+ }
1207
+
1208
+ return this.getChannelById(channelType, channelID, custom);
1209
+ }
1210
+
1211
+ getChannelById = (channelType: string, channelID: string, custom: ChannelData<ErmisChatGenerics>) => {
1212
+ const cid = `${channelType}:${channelID}`;
1213
+ if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1214
+ const channel = this.activeChannels[cid];
1215
+ if (Object.keys(custom).length > 0) {
1216
+ channel.data = custom;
1217
+ channel._data = custom;
1218
+ }
1219
+ return channel;
1220
+ }
1221
+ const channel = new Channel<ErmisChatGenerics>(this, channelType, channelID, custom);
1222
+ this.activeChannels[channel.cid] = channel;
1223
+
1224
+ return channel;
1225
+ };
1226
+
1227
+ getChannel = (channelType: string, custom: ChannelData<ErmisChatGenerics>) => {
1228
+ const uuid = randomId();
1229
+ const id = `${this.projectId}:${uuid}`;
1230
+ // only allow 1 channel object per cid
1231
+ const cid = `${channelType}:${id}`;
1232
+ if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1233
+ const channel = this.activeChannels[cid];
1234
+ if (Object.keys(custom).length > 0) {
1235
+ channel.data = custom;
1236
+ channel._data = custom;
1237
+ }
1238
+ return channel;
1239
+ }
1240
+ const channel = new Channel<ErmisChatGenerics>(this, channelType, id, custom);
1241
+ this.activeChannels[channel.cid] = channel;
1242
+
1243
+ return channel;
1244
+ };
1245
+
1246
+ _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) {
1247
+ let pinExpires: null | string = null;
1248
+ if (typeof timeoutOrExpirationDate === 'number') {
1249
+ const now = new Date();
1250
+ now.setSeconds(now.getSeconds() + timeoutOrExpirationDate);
1251
+ pinExpires = now.toISOString();
1252
+ } else if (isString(timeoutOrExpirationDate)) {
1253
+ pinExpires = timeoutOrExpirationDate;
1254
+ } else if (timeoutOrExpirationDate instanceof Date) {
1255
+ pinExpires = timeoutOrExpirationDate.toISOString();
1256
+ }
1257
+ return pinExpires;
1258
+ }
1259
+
1260
+ getUserAgent() {
1261
+ return (
1262
+ this.userAgent || `ermis-chat-sdk-javascript-client-${this.node ? 'node' : 'browser'}-${process.env.PKG_VERSION}`
1263
+ );
1264
+ }
1265
+
1266
+ setUserAgent(userAgent: string) {
1267
+ this.userAgent = userAgent;
1268
+ }
1269
+
1270
+ _enrichAxiosOptions(
1271
+ options: AxiosRequestConfig & { config?: AxiosRequestConfig } = {
1272
+ params: {},
1273
+ headers: {},
1274
+ config: {},
1275
+ },
1276
+ ): AxiosRequestConfig {
1277
+ let token = this._getToken();
1278
+
1279
+ if (!token?.startsWith('Bearer ')) {
1280
+ token = `Bearer ${token}`;
1281
+ }
1282
+
1283
+ const authorization = token ? { Authorization: token } : undefined;
1284
+
1285
+ if (!options.headers?.['x-client-request-id']) {
1286
+ options.headers = {
1287
+ ...options.headers,
1288
+ 'x-client-request-id': randomId(),
1289
+ };
1290
+ }
1291
+ const {
1292
+ params: axiosRequestConfigParams,
1293
+ headers: axiosRequestConfigHeaders,
1294
+ ...axiosRequestConfigRest
1295
+ } = this.options.axiosRequestConfig || {};
1296
+
1297
+ let user_service_params = {
1298
+ ...options.params,
1299
+ ...(axiosRequestConfigParams || {}),
1300
+ };
1301
+
1302
+ return {
1303
+ params: user_service_params,
1304
+ headers: {
1305
+ ...authorization,
1306
+ 'stream-auth-type': this.getAuthType(),
1307
+ 'X-Stream-Client': this.getUserAgent(),
1308
+ ...options.headers,
1309
+ ...(axiosRequestConfigHeaders || {}),
1310
+ },
1311
+
1312
+ ...options.config,
1313
+ ...(axiosRequestConfigRest || {}),
1314
+ };
1315
+ }
1316
+
1317
+ _getToken() {
1318
+ if (!this.tokenManager) return null;
1319
+
1320
+ return this.tokenManager.getToken();
1321
+ }
1322
+
1323
+ _startCleaning() {
1324
+ const that = this;
1325
+ if (this.cleaningIntervalRef != null) {
1326
+ return;
1327
+ }
1328
+ this.cleaningIntervalRef = setInterval(() => {
1329
+ // call clean on the channel, used for calling the stop.typing event etc.
1330
+ for (const channel of Object.values(that.activeChannels)) {
1331
+ channel.clean();
1332
+ }
1333
+ }, 500);
1334
+ }
1335
+
1336
+ _buildWSPayload = (client_request_id?: string) => {
1337
+ return JSON.stringify({
1338
+ user_id: this.userID,
1339
+ user_details: this.user,
1340
+ client_request_id,
1341
+ });
1342
+ };
1343
+ }