@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/client.ts DELETED
@@ -1,1617 +0,0 @@
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
- /**
57
- * The ErmisChat Client represents the connection securely established between your application
58
- * and the Ermis core servers. It acts as the primary access point for real-time messaging,
59
- * presence updates, call management, and channel querying.
60
- *
61
- * It is highly recommended to instantiate this class as a Singleton via `ErmisChat.getInstance()`.
62
- */
63
- export class ErmisChat<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics> {
64
- private static _instance?: unknown | ErmisChat; // type is undefined|ErmisChat, unknown is due to TS limitations with statics
65
-
66
- /** A map of active channels currently tracked by the client, keyed by Channel ID. */
67
- activeChannels: {
68
- [key: string]: Channel<ErmisChatGenerics>;
69
- };
70
- /** The internal configured Axios instance used for REST API requests. */
71
- axiosInstance: AxiosInstance;
72
- /** The primary base URL for REST API communication. */
73
- baseURL?: string;
74
- /** The specific base URL for standard user-focused REST calls. */
75
- userBaseURL?: string;
76
- /** True when the SDK is executed inside a browser environment. */
77
- browser: boolean;
78
- cleaningIntervalRef?: NodeJS.Timeout;
79
- clientID?: string;
80
- apiKey: string;
81
- projectId: string;
82
- /** Internal mapped registry of event listeners. */
83
- listeners: Record<string, Array<(event: Event<ErmisChatGenerics>) => void>>;
84
- logger: Logger;
85
- /** Whether the client should automatically fetch missing messages upon unexpected disconnects. */
86
- recoverStateOnReconnect?: boolean;
87
- /** True when the SDK is executed in a NodeJS environment constraint. */
88
- node: boolean;
89
- /** Custom options passed during client initialization. */
90
- options: ErmisChatOptions;
91
- setUserPromise: ConnectAPIResponse<ErmisChatGenerics> | null;
92
- /** Centralized global state orchestrating user and client metadata. */
93
- state: ClientState<ErmisChatGenerics>;
94
- tokenManager: TokenManager<ErmisChatGenerics>;
95
- /** The globally authenticated current user object. */
96
- user?: UserResponse<ErmisChatGenerics>;
97
- userAgent?: string;
98
- /** The unique ID of the current authenticated user. */
99
- userID?: string;
100
- /** The configured WebSocket endpoint base URL for realtime subscriptions. */
101
- wsBaseURL?: string;
102
- /** The active WebSocket connection controller. */
103
- wsConnection: StableWSConnection<ErmisChatGenerics> | null;
104
- wsPromise: ConnectAPIResponse<ErmisChatGenerics> | null;
105
- /** Tracks consecutive REST API failures for exponential backoff purposes. */
106
- consecutiveFailures: number;
107
- defaultWSTimeout: number;
108
-
109
- private eventSource: EventSourcePolyfill | null = null;
110
-
111
- /**
112
- * Initializes a new Ermis Chat Client instance.
113
- *
114
- * @param apiKey - Your public Ermis Network API Key.
115
- * @param projectId - Your specific Project UUID pointing to the app config.
116
- * @param baseURL - The API base endpoint assigned to your project.
117
- * @param options - Additional connection rules and configuration options.
118
- */
119
- constructor(apiKey: string, projectId: string, baseURL: string, options?: ErmisChatOptions) {
120
- this.apiKey = apiKey;
121
- this.projectId = projectId;
122
- this.listeners = {};
123
- this.state = new ClientState<ErmisChatGenerics>();
124
-
125
- const inputOptions = options || {};
126
-
127
- this.browser = typeof inputOptions.browser !== 'undefined' ? inputOptions.browser : typeof window !== 'undefined';
128
- this.node = !this.browser;
129
-
130
- this.options = {
131
- withCredentials: false,
132
- warmUp: false,
133
- recoverStateOnReconnect: true,
134
- ...inputOptions,
135
- };
136
-
137
- if (this.node && !this.options.httpsAgent) {
138
- this.options.httpsAgent = new https.Agent({
139
- keepAlive: true,
140
- keepAliveMsecs: 3000,
141
- });
142
- }
143
-
144
- this.axiosInstance = axios.create(this.options);
145
-
146
- this.setBaseURL(baseURL);
147
-
148
- // WS connection is initialized when setUser is called
149
- this.wsConnection = null;
150
- this.wsPromise = null;
151
- this.setUserPromise = null;
152
- // keeps a reference to all the channels that are in use
153
- this.activeChannels = {};
154
-
155
- this.tokenManager = new TokenManager();
156
- this.consecutiveFailures = 0;
157
- this.defaultWSTimeout = 15000;
158
-
159
- this.axiosInstance.defaults.paramsSerializer = axiosParamsSerializer;
160
-
161
- this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null;
162
- this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
163
- }
164
-
165
- /**
166
- * Retrieves the globally registered Singleton instance of the ErmisChat client.
167
- * If the instance lacks existence, it initializes a new one.
168
- *
169
- * @param key - Your public Ermis Network API Key.
170
- * @param projectId - Your specific Project UUID.
171
- * @param baseURL - The API base endpoint.
172
- * @param options - Connection options.
173
- * @returns The shared ErmisChat client instance.
174
- */
175
- public static getInstance<ErmisChatGenerics extends ExtendableGenerics = DefaultGenerics>(
176
- key: string,
177
- projectId: string,
178
- baseURL: string,
179
- options?: ErmisChatOptions,
180
- ): ErmisChat<ErmisChatGenerics> {
181
- if (!ErmisChat._instance) {
182
- ErmisChat._instance = new ErmisChat<ErmisChatGenerics>(key, projectId, baseURL, options);
183
- }
184
-
185
- return ErmisChat._instance as ErmisChat<ErmisChatGenerics>;
186
- }
187
-
188
- async refreshNewToken(refresh_token: string) {
189
- return await this.post<APIResponse>(this.userBaseURL + '/refresh_token', { refresh_token });
190
- }
191
-
192
- getAuthType() {
193
- return 'jwt';
194
- }
195
-
196
- setBaseURL(baseURL: string) {
197
- this.baseURL = baseURL;
198
- this.userBaseURL = this.options.userBaseURL || baseURL + '/uss/v1';
199
- this.wsBaseURL = this.baseURL.replace('http', 'ws').replace(':3030', ':8800');
200
- }
201
-
202
- async getExternalAuthToken(user: UserResponse<ErmisChatGenerics>, token: string | null) {
203
- const params: any = { apikey: this.apiKey, name: user.name };
204
- if (user.avatar) {
205
- params.avatar = user.avatar;
206
- }
207
- const url = this.userBaseURL + '/get_token/external_auth';
208
- const headers: Record<string, string> = {
209
- 'Content-Type': 'application/json',
210
- };
211
- if (token) {
212
- const tokenStr = typeof token === 'string' && token.startsWith('Bearer ') ? token : `Bearer ${token}`;
213
- headers['Authorization'] = tokenStr;
214
- }
215
- try {
216
- const response = await this.axiosInstance.get(url, {
217
- params,
218
- headers,
219
- });
220
- return response.data;
221
- } catch (error: any) {
222
- let errorMsg = 'Failed to fetch external auth token';
223
- if (error.response && error.response.data) {
224
- errorMsg = error.response.data.message || JSON.stringify(error.response.data);
225
- } else if (error.message) {
226
- errorMsg = error.message;
227
- }
228
- throw new Error(errorMsg);
229
- }
230
- }
231
-
232
- /**
233
- * Connects a user to the Ermis network and establishes the WebSocket connection.
234
- * This is the primary method to authenticate your client application.
235
- *
236
- * @param user - The User object containing `id`, `name`, and optional `avatar`.
237
- * @param userTokenOrProvider - The JWT token or an async token provider function.
238
- * @param extenal_auth - Set to `true` to use your custom backend external authentication flow.
239
- * @returns A promise resolving to the API connection response once authenticated.
240
- */
241
- connectUser = async (
242
- user: UserResponse<ErmisChatGenerics>,
243
- userTokenOrProvider: string | null,
244
- external_auth?: boolean, // pass true if you are using external auth
245
- ) => {
246
- this.logger('info', 'client:connectUser() - started', {
247
- tags: ['connection', 'client'],
248
- });
249
- if (!user.id) {
250
- throw new Error('The "id" field on the user is missing');
251
- }
252
-
253
- let connectionUser = user;
254
- let connectionToken = userTokenOrProvider;
255
-
256
- // If external auth is enabled, get the token from the server
257
- if (external_auth) {
258
- const external_auth_token = await this.getExternalAuthToken(user, userTokenOrProvider);
259
-
260
- connectionToken = external_auth_token.token;
261
- connectionUser = {
262
- ...user,
263
- id: external_auth_token.user_id,
264
- };
265
- }
266
-
267
- /**
268
- * Calling connectUser multiple times is potentially the result of a bad integration, however,
269
- * If the user id remains the same we don't throw error
270
- */
271
- if (this.userID === connectionUser.id && this.setUserPromise) {
272
- console.warn(
273
- 'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.',
274
- );
275
- return this.setUserPromise;
276
- }
277
-
278
- if (this.userID) {
279
- throw new Error(
280
- 'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.',
281
- );
282
- }
283
-
284
- if (this.node && !this.options.allowServerSideConnect) {
285
- console.warn(
286
- '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.',
287
- );
288
- }
289
-
290
- // we generate the client id client side
291
- this.userID = connectionUser.id;
292
-
293
- const setTokenPromise = this._setToken(connectionUser, connectionToken);
294
- this._setUser(connectionUser);
295
- this.state.updateUser({ id: connectionUser.id, name: connectionUser?.name || connectionUser.id, avatar: connectionUser?.avatar || '' });
296
-
297
- const wsPromise = this.openConnection();
298
-
299
- this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
300
- (result) => result[1], // We only return connection promise;
301
- );
302
-
303
- try {
304
- const result = await this.setUserPromise;
305
- // Call SSE after successful connect
306
- await this.connectToSSE();
307
- return result;
308
- } catch (err) {
309
- this.disconnectUser();
310
- throw err;
311
- }
312
- };
313
-
314
- setUser = this.connectUser;
315
-
316
- _setToken = (user: UserResponse<ErmisChatGenerics>, userTokenOrProvider: string | null) =>
317
- this.tokenManager.setTokenOrProvider(userTokenOrProvider, user);
318
-
319
- _setUser(user: UserResponse<ErmisChatGenerics>) {
320
- this.user = { ...user };
321
- this.userID = user.id;
322
- }
323
-
324
- closeConnection = async (timeout?: number) => {
325
- if (this.cleaningIntervalRef != null) {
326
- clearInterval(this.cleaningIntervalRef);
327
- this.cleaningIntervalRef = undefined;
328
- }
329
-
330
- await this.wsConnection?.disconnect(timeout);
331
- return Promise.resolve();
332
- };
333
-
334
- openConnection = async () => {
335
- if (!this.userID) {
336
- throw Error('User is not set on client, use client.connectUser instead');
337
- }
338
-
339
- if (this.wsConnection?.isConnecting && this.wsPromise) {
340
- this.logger('info', 'client:openConnection() - connection already in progress', {
341
- tags: ['connection', 'client'],
342
- });
343
- return this.wsPromise;
344
- }
345
-
346
- if (this.wsConnection?.isHealthy) {
347
- this.logger('info', 'client:openConnection() - openConnection called twice, healthy connection already exists', {
348
- tags: ['connection', 'client'],
349
- });
350
-
351
- return Promise.resolve();
352
- }
353
-
354
- this.clientID = `${this.userID}--${randomId()}`;
355
- this.wsPromise = this.connect();
356
- this._startCleaning();
357
- return this.wsPromise;
358
- };
359
-
360
- _setupConnection = this.openConnection;
361
-
362
- /**
363
- * Gracefully disconnects the current user, terminates the WebSocket connection,
364
- * cleans up listeners, and resets the client's internal references.
365
- *
366
- * @param timeout - Optional timeout in milliseconds before forcing the disconnect.
367
- */
368
- disconnectUser = async (timeout?: number) => {
369
- this.logger('info', 'client:disconnect() - Disconnecting the client', {
370
- tags: ['connection', 'client'],
371
- });
372
-
373
- // remove the user specific fields
374
- delete this.user;
375
- delete this.userID;
376
-
377
- const closePromise = this.closeConnection(timeout);
378
-
379
- for (const channel of Object.values(this.activeChannels)) {
380
- channel._disconnect();
381
- }
382
- // ensure we no longer return inactive channels
383
- this.activeChannels = {};
384
- // reset client state
385
- this.state = new ClientState();
386
- // reset token manager
387
- setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls
388
-
389
- // close the WS connection
390
- return closePromise;
391
- };
392
-
393
- disconnect = this.disconnectUser;
394
-
395
- /**
396
- * Attaches an event listener to the client connection.
397
- * Listeners can be scoped to specific event types (e.g. `message.new`) or listen to `all` events.
398
- *
399
- * @param callback - The handler invoked when the event is emitted.
400
- * @returns An object containing an `unsubscribe` method to detach the listener.
401
- */
402
- on(callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
403
- /**
404
- * Attaches an event listener filtered by a specific event type.
405
- *
406
- * @param eventType - The specific event name to listen for (e.g., `'notification.message_new'`).
407
- * @param callback - The handler invoked when the event is emitted.
408
- * @returns An object containing an `unsubscribe` method to detach the listener.
409
- */
410
- on(eventType: string, callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
411
- on(
412
- callbackOrString: EventHandler<ErmisChatGenerics> | string,
413
- callbackOrNothing?: EventHandler<ErmisChatGenerics>,
414
- ): { unsubscribe: () => void } {
415
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
416
- const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
417
- if (!(key in this.listeners)) {
418
- this.listeners[key] = [];
419
- }
420
- this.logger('info', `Attaching listener for ${key} event`, {
421
- tags: ['event', 'client'],
422
- });
423
- this.listeners[key].push(callback);
424
- return {
425
- unsubscribe: () => {
426
- this.logger('info', `Removing listener for ${key} event`, {
427
- tags: ['event', 'client'],
428
- });
429
- this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
430
- },
431
- };
432
- }
433
-
434
- /**
435
- * Detaches a previously registered general event listener.
436
- * @param callback - The original handler reference to remove.
437
- */
438
- off(callback: EventHandler<ErmisChatGenerics>): void;
439
- /**
440
- * Detaches a previously registered event listener scoped to a specific event type.
441
- * @param eventType - The specific event name.
442
- * @param callback - The original handler reference to remove.
443
- */
444
- off(eventType: string, callback: EventHandler<ErmisChatGenerics>): void;
445
- off(callbackOrString: EventHandler<ErmisChatGenerics> | string, callbackOrNothing?: EventHandler<ErmisChatGenerics>) {
446
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
447
- const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
448
- if (!(key in this.listeners)) {
449
- this.listeners[key] = [];
450
- }
451
-
452
- this.logger('info', `Removing listener for ${key} event`, {
453
- tags: ['event', 'client'],
454
- });
455
- this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
456
- }
457
-
458
- _logApiRequest(
459
- type: string,
460
- url: string,
461
- data: unknown,
462
- config: AxiosRequestConfig & {
463
- config?: AxiosRequestConfig & { maxBodyLength?: number };
464
- },
465
- ) {
466
- this.logger(
467
- 'info',
468
- `client: ${type} - Request - ${url}- ${JSON.stringify(data)} - ${JSON.stringify(config.params)}`,
469
- {
470
- tags: ['api', 'api_request', 'client'],
471
- url,
472
- payload: data,
473
- config,
474
- },
475
- );
476
- }
477
-
478
- _logApiResponse<T>(type: string, url: string, response: AxiosResponse<T>) {
479
- this.logger('info', `client:${type} - Response - url: ${url} > status ${response.status}`, {
480
- tags: ['api', 'api_response', 'client'],
481
- url,
482
- response,
483
- });
484
- }
485
-
486
- _logApiError(type: string, url: string, error: unknown, options: unknown) {
487
- this.logger(
488
- 'error',
489
- `client:${type} - Error: ${JSON.stringify(error)} - url: ${url} - options: ${JSON.stringify(options)}`,
490
- {
491
- tags: ['api', 'api_response', 'client'],
492
- url,
493
- error,
494
- },
495
- );
496
- }
497
-
498
- doAxiosRequest = async <T>(
499
- type: string,
500
- url: string,
501
- data?: unknown,
502
- options: AxiosRequestConfig & {
503
- config?: AxiosRequestConfig & { maxBodyLength?: number };
504
- } = {},
505
- ): Promise<T> => {
506
- await this.tokenManager.tokenReady();
507
-
508
- const requestConfig = this._enrichAxiosOptions(options);
509
-
510
- try {
511
- let response: AxiosResponse<T>;
512
- this._logApiRequest(type, url, data, requestConfig);
513
- switch (type) {
514
- case 'get':
515
- response = await this.axiosInstance.get(url, requestConfig);
516
- break;
517
- case 'delete':
518
- response = await this.axiosInstance.delete(url, requestConfig);
519
- break;
520
- case 'post':
521
- response = await this.axiosInstance.post(url, data, requestConfig);
522
- break;
523
- case 'postForm':
524
- response = await this.axiosInstance.postForm(url, data, requestConfig);
525
- break;
526
- case 'put':
527
- response = await this.axiosInstance.put(url, data, requestConfig);
528
- break;
529
- case 'patch':
530
- response = await this.axiosInstance.patch(url, data, requestConfig);
531
- break;
532
- case 'options':
533
- response = await this.axiosInstance.options(url, requestConfig);
534
- break;
535
- default:
536
- throw new Error('Invalid request type');
537
- }
538
- this._logApiResponse<T>(type, url, response);
539
- this.consecutiveFailures = 0;
540
- return this.handleResponse(response);
541
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
542
- } catch (e: any /**TODO: generalize error types */) {
543
- e.client_request_id = requestConfig.headers?.['x-client-request-id'];
544
- this._logApiError(type, url, e, options);
545
- this.consecutiveFailures += 1;
546
- if (e.response) {
547
- return this.handleResponse(e.response);
548
- } else {
549
- throw e as AxiosError<APIErrorResponse>;
550
- }
551
- }
552
- };
553
-
554
- get<T>(url: string, params?: AxiosRequestConfig['params']) {
555
- return this.doAxiosRequest<T>('get', url, null, { params });
556
- }
557
-
558
- put<T>(url: string, data?: unknown) {
559
- return this.doAxiosRequest<T>('put', url, data);
560
- }
561
-
562
- post<T>(url: string, data?: unknown, params?: AxiosRequestConfig['params']) {
563
- return this.doAxiosRequest<T>('post', url, data, { params });
564
- }
565
-
566
- patch<T>(url: string, data?: unknown) {
567
- return this.doAxiosRequest<T>('patch', url, data);
568
- }
569
-
570
- delete<T>(url: string, params?: AxiosRequestConfig['params']) {
571
- return this.doAxiosRequest<T>('delete', url, null, { params });
572
- }
573
-
574
- sendFile(
575
- url: string,
576
- uri: string | NodeJS.ReadableStream | Buffer | File,
577
- name?: string,
578
- contentType?: string,
579
- user?: UserResponse<ErmisChatGenerics>,
580
- ) {
581
- const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
582
- if (user != null) data.append('user', JSON.stringify(user));
583
-
584
- return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, {
585
- headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser
586
- config: {
587
- timeout: 0,
588
- maxContentLength: Infinity,
589
- maxBodyLength: Infinity,
590
- },
591
- });
592
- }
593
-
594
- /**
595
- * Downloads a media file as a Blob via the SDK's configured axiosInstance.
596
- * This avoids CORS issues that arise when using `fetch()` directly from the browser,
597
- * because axios is routed through the SDK's authenticated transport layer.
598
- *
599
- * @param url - The full URL of the media file to download.
600
- * @returns A Blob of the file content.
601
- */
602
- async downloadMedia(url: string): Promise<Blob> {
603
- const response = await this.axiosInstance.get(url, {
604
- responseType: 'blob',
605
- });
606
- return response.data as Blob;
607
- }
608
-
609
- errorFromResponse(response: AxiosResponse<APIErrorResponse>): ErrorFromResponse<APIErrorResponse> {
610
- let err: ErrorFromResponse<APIErrorResponse>;
611
- err = new ErrorFromResponse(`ErmisChat error HTTP code: ${response.status}`);
612
- if (response.data && response.data.code) {
613
- err = new Error(`ErmisChat error code ${response.data.code}: ${response.data.message}`);
614
- err.code = response.data.code;
615
- }
616
- err.response = response;
617
- err.status = response.status;
618
- return err;
619
- }
620
-
621
- handleResponse<T>(response: AxiosResponse<T>) {
622
- const data = response.data;
623
- if (isErrorResponse(response)) {
624
- throw this.errorFromResponse(response);
625
- }
626
- return data;
627
- }
628
-
629
- dispatchEvent = (event: Event<ErmisChatGenerics>) => {
630
- if (!event.received_at) event.received_at = new Date();
631
-
632
- // If the event is channel.created or channel.topic.created, handle it asynchronously
633
- if (event.type === 'channel.created' || event.type === 'channel.topic.created') {
634
- this._handleChannelCreatedEvent(event).then(() => {
635
- this._afterDispatchEvent(event);
636
- });
637
- } else {
638
- const postListenerCallbacks = this._handleClientEvent(event);
639
-
640
- // channel event handlers
641
- const cid = event.cid;
642
- const channel = cid ? this.activeChannels[cid] : undefined;
643
- if (channel) {
644
- channel._handleChannelEvent(event);
645
- }
646
-
647
- this._callClientListeners(event);
648
-
649
- if (channel) {
650
- channel._callChannelListeners(event);
651
- }
652
-
653
- postListenerCallbacks.forEach((c) => c());
654
- }
655
- };
656
-
657
- _afterDispatchEvent(event: Event<ErmisChatGenerics>) {
658
- const postListenerCallbacks = this._handleClientEvent(event);
659
-
660
- const cid = event.cid;
661
- const channel = cid ? this.activeChannels[cid] : undefined;
662
- if (channel) {
663
- channel._handleChannelEvent(event);
664
- }
665
-
666
- this._callClientListeners(event);
667
-
668
- if (channel) {
669
- channel._callChannelListeners(event);
670
- }
671
-
672
- postListenerCallbacks.forEach((c) => c());
673
- }
674
-
675
- private async _handleChannelCreatedEvent(event: Event<ErmisChatGenerics>) {
676
- const members = event.channel?.members || [];
677
- // Ensure all members' user info are loaded in state.users
678
- await ensureMembersUserInfoLoaded(this, members);
679
-
680
- // Get the latest users after updating
681
- const updatedUsers = Object.values(this.state.users);
682
-
683
- const enrichedMembers = enrichWithUserInfo(members, updatedUsers);
684
- const channelName =
685
- event.channel_type === 'messaging'
686
- ? getDirectChannelName(enrichedMembers, this.userID || '')
687
- : event.channel?.name;
688
- const channel = {
689
- ...event.channel,
690
- members: enrichedMembers,
691
- name: channelName,
692
- };
693
- const channelState: any = {
694
- channel,
695
- members: enrichedMembers,
696
- messages: [],
697
- pinned_messages: [],
698
- };
699
- const c = this.channel(event.channel_type || '', event.channel_id || '');
700
- c.data = channel;
701
- c._initializeState(channelState, 'latest');
702
- }
703
-
704
- handleEvent = (messageEvent: WebSocket.MessageEvent) => {
705
- // dispatch the event to the channel listeners
706
- const jsonString = messageEvent.data as string;
707
- const event = JSON.parse(jsonString) as Event<ErmisChatGenerics>;
708
- this.dispatchEvent(event);
709
- };
710
-
711
- _updateMemberWatcherReferences = (user: UserResponse<ErmisChatGenerics>) => {
712
- const refMap = this.state.userChannelReferences[user.id] || {};
713
- for (const channelID in refMap) {
714
- const channel = this.activeChannels[channelID];
715
- if (channel?.state) {
716
- if (channel.state.members[user.id]) {
717
- channel.state.members[user.id].user = user;
718
- }
719
- if (channel.state.watchers[user.id]) {
720
- channel.state.watchers[user.id] = user;
721
- }
722
- if (channel.state.read[user.id]) {
723
- channel.state.read[user.id].user = user;
724
- }
725
- }
726
- }
727
- };
728
-
729
- _updateUserReferences = this._updateMemberWatcherReferences;
730
-
731
- _updateUserMessageReferences = (user: UserResponse<ErmisChatGenerics>) => {
732
- const refMap = this.state.userChannelReferences[user.id] || {};
733
-
734
- for (const channelID in refMap) {
735
- const channel = this.activeChannels[channelID];
736
-
737
- if (!channel) continue;
738
-
739
- const state = channel.state;
740
-
741
- /** update the messages from this user. */
742
- state?.updateUserMessages(user);
743
- }
744
- };
745
-
746
- _deleteUserMessageReference = (user: UserResponse<ErmisChatGenerics>, hardDelete = false) => {
747
- const refMap = this.state.userChannelReferences[user.id] || {};
748
-
749
- for (const channelID in refMap) {
750
- const channel = this.activeChannels[channelID];
751
- const state = channel.state;
752
-
753
- /** deleted the messages from this user. */
754
- state?.deleteUserMessages(user, hardDelete);
755
- }
756
- };
757
-
758
- _handleClientEvent(event: Event<ErmisChatGenerics>) {
759
- const client = this;
760
- const postListenerCallbacks = [];
761
- this.logger('info', `client:_handleClientEvent - Received event of type { ${event.type} }`, {
762
- tags: ['event', 'client'],
763
- event,
764
- });
765
-
766
- if (event.type === 'health.check' && event.me) {
767
- }
768
-
769
- if ((event.type === 'channel.deleted' || event.type === 'notification.channel_deleted') && event.cid) {
770
- client.state.deleteAllChannelReference(event.cid);
771
- this.activeChannels[event.cid]?._disconnect();
772
-
773
- postListenerCallbacks.push(() => {
774
- if (!event.cid) return;
775
-
776
- delete this.activeChannels[event.cid];
777
- });
778
-
779
- for (const channel of Object.values(this.activeChannels)) {
780
- if (channel.type === 'team' && channel.state.topics?.some((t) => t.cid === event.cid)) {
781
- // Remove the topic with matching cid from the topics array
782
- channel.state.topics = channel.state.topics.filter((t) => t.cid !== event.cid);
783
- }
784
- }
785
- }
786
- if (event.type === 'notification.invite_rejected') {
787
- if (event.member?.user_id === this.userID && event.cid) {
788
- client.state.deleteAllChannelReference(event.cid);
789
- this.activeChannels[event.cid]?._disconnect();
790
-
791
- postListenerCallbacks.push(() => {
792
- if (!event.cid) return;
793
-
794
- delete this.activeChannels[event.cid];
795
- });
796
- }
797
- }
798
- if (event.type === 'notification.invite_accepted') {
799
- //TODO handle channel list and invited channels here
800
- }
801
-
802
- if (event.type === 'member.added') {
803
- if (event.member?.user_id === this.userID) {
804
- const c = this.channel(event.channel_type || '', event.channel_id || '');
805
- // Gọi watch để lấy đầy đủ thông tin channel từ server
806
- c.watch().catch((err) => {
807
- this.logger('error', 'Failed to watch channel after member.added', { err, event });
808
- });
809
- }
810
- }
811
-
812
- if (event.type === 'message.new' && event.channel_type === 'topic') {
813
- postListenerCallbacks.push(() => {
814
- const parentCid = event.parent_cid || event.channel?.parent_cid;
815
- if (parentCid && this.activeChannels[parentCid]) {
816
- const parentChannel = this.activeChannels[parentCid];
817
- if (parentChannel.state.topics) {
818
- parentChannel.state.topics.sort((a, b) => {
819
- const aLatest = a.state?.latestMessages?.[a.state.latestMessages.length - 1]?.created_at;
820
- const bLatest = b.state?.latestMessages?.[b.state.latestMessages.length - 1]?.created_at;
821
- const aTime = aLatest ? new Date(aLatest).getTime() : 0;
822
- const bTime = bLatest ? new Date(bLatest).getTime() : 0;
823
- return bTime - aTime;
824
- });
825
- parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
826
- }
827
- }
828
- });
829
- }
830
- if (event.type === 'channel.topic.updated') {
831
- postListenerCallbacks.push(() => {
832
- const parentCid = event.parent_cid || event.channel?.parent_cid;
833
- if (parentCid && this.activeChannels[parentCid]) {
834
- const parentChannel = this.activeChannels[parentCid];
835
- if (parentChannel.state?.topics && event.channel) {
836
- const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
837
- if (topicIndex !== -1) {
838
- const t = parentChannel.state.topics[topicIndex] as any;
839
- if (t.data) {
840
- t.data = { ...t.data, ...event.channel };
841
- } else if (t.channel) {
842
- t.channel = { ...t.channel, ...event.channel };
843
- } else {
844
- Object.assign(t, event.channel);
845
- }
846
- }
847
- parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
848
- }
849
- }
850
-
851
- if (event.cid && this.activeChannels[event.cid]) {
852
- const topicChannel = this.activeChannels[event.cid];
853
- if (event.channel) {
854
- topicChannel.data = { ...topicChannel.data, ...event.channel };
855
- topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
856
- }
857
- }
858
- });
859
- }
860
-
861
- if (event.type === 'channel.topic.closed' || event.type === 'channel.topic.reopen') {
862
- postListenerCallbacks.push(() => {
863
- const isClosed = event.type === 'channel.topic.closed';
864
- const parentCid = event.parent_cid;
865
- if (parentCid && this.activeChannels[parentCid]) {
866
- const parentChannel = this.activeChannels[parentCid];
867
- if (parentChannel.state?.topics) {
868
- const topicIndex = parentChannel.state.topics.findIndex((t: any) => t.cid === event.cid || t.channel?.cid === event.cid);
869
- if (topicIndex !== -1) {
870
- const t = parentChannel.state.topics[topicIndex] as any;
871
- if (t.data) t.data.is_closed_topic = isClosed;
872
- else if (t.channel) t.channel.is_closed_topic = isClosed;
873
- else t.is_closed_topic = isClosed;
874
- }
875
- parentChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: parentChannel.data } as any);
876
- }
877
- }
878
-
879
- if (event.cid && this.activeChannels[event.cid]) {
880
- const topicChannel = this.activeChannels[event.cid];
881
- if (topicChannel.data) {
882
- topicChannel.data.is_closed_topic = isClosed;
883
- }
884
- topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
885
- }
886
- });
887
- }
888
-
889
-
890
-
891
- if (event.type === 'connection.recovered') {
892
- postListenerCallbacks.push(() => {
893
- // Auto-resend offline failed messages
894
- Object.values(this.activeChannels).forEach((channel) => {
895
- if (!channel.state?.messages) return;
896
- const offlineFailedMsgs = channel.state.messages.filter(
897
- (m) =>
898
- m.status === 'failed_offline' &&
899
- m.user?.id === this.userID &&
900
- (!m.attachments || m.attachments.length === 0),
901
- );
902
- offlineFailedMsgs.forEach((msg) => {
903
- if (msg.id) {
904
- channel.retryMessage(msg.id).catch((err) => {
905
- this.logger('error', `Failed to auto-resend offline message ${msg.id}`, {
906
- tags: ['offline', 'retry'],
907
- err,
908
- });
909
- });
910
- }
911
- });
912
- });
913
- });
914
- }
915
-
916
- return postListenerCallbacks;
917
- }
918
-
919
- _callClientListeners = (event: Event<ErmisChatGenerics>) => {
920
- const client = this;
921
- // gather and call the listeners
922
- const listeners: Array<(event: Event<ErmisChatGenerics>) => void> = [];
923
- if (client.listeners.all) {
924
- listeners.push(...client.listeners.all);
925
- }
926
- if (client.listeners[event.type]) {
927
- listeners.push(...client.listeners[event.type]);
928
- }
929
-
930
- // call the event and send it to the listeners
931
- for (const listener of listeners) {
932
- listener(event);
933
- }
934
- };
935
-
936
- recoverState = async () => {
937
- this.logger('info', 'client:recoverState() - Start of recoverState', {
938
- tags: ['connection'],
939
- });
940
-
941
- const cids = Object.keys(this.activeChannels);
942
- if (cids.length && this.recoverStateOnReconnect) {
943
- this.logger('info', `client:recoverState() - Start the querying of ${cids.length} channels`, {
944
- tags: ['connection', 'client'],
945
- });
946
-
947
- const filter: ChannelFilters = {
948
- type: ['messaging', 'team'],
949
- };
950
- const sort: [] = [];
951
- const options = {
952
- message_limit: 25,
953
- };
954
-
955
- await this.queryChannels(filter, sort, options);
956
-
957
- this.logger('info', 'client:recoverState() - Querying channels finished', { tags: ['connection', 'client'] });
958
- this.dispatchEvent({
959
- type: 'connection.recovered',
960
- } as Event<ErmisChatGenerics>);
961
- } else {
962
- this.dispatchEvent({
963
- type: 'connection.recovered',
964
- } as Event<ErmisChatGenerics>);
965
- }
966
-
967
- this.wsPromise = Promise.resolve();
968
- this.setUserPromise = Promise.resolve();
969
- };
970
-
971
- async connect() {
972
- if (!this.userID || !this.user) {
973
- throw Error('Call connectUser before starting the connection');
974
- }
975
- if (!this.wsBaseURL) {
976
- throw Error('Websocket base url not set');
977
- }
978
- if (!this.clientID) {
979
- throw Error('clientID is not set');
980
- }
981
-
982
- // if (!this.wsConnection && (this.options.warmUp || this.options.enableInsights)) {
983
- // this._sayHi();
984
- // }
985
- // The StableWSConnection handles all the reconnection logic.
986
- if (this.options.wsConnection && this.node) {
987
- // Intentionally avoiding adding ts generics on wsConnection in options since its only useful for unit test purpose.
988
- (this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>).setClient(this);
989
- this.wsConnection = this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>;
990
- } else {
991
- this.wsConnection = new StableWSConnection<ErmisChatGenerics>({
992
- client: this,
993
- });
994
- }
995
-
996
- try {
997
- return await this.wsConnection.connect(this.defaultWSTimeout);
998
- } catch (err: any) {
999
- throw err;
1000
- }
1001
- }
1002
- public async connectToSSE(onCallBack?: (data: any) => void): Promise<void> {
1003
- if (this.eventSource) {
1004
- this.logger('info', 'client:connectToSSE() - SSE connection already established', {});
1005
- return;
1006
- }
1007
- let token = this._getToken();
1008
-
1009
- if (!token?.startsWith('Bearer ')) {
1010
- token = `Bearer ${token}`;
1011
- }
1012
- const headers = {
1013
- method: 'GET',
1014
- Authorization: token,
1015
- };
1016
- this.eventSource = new EventSourcePolyfill(this.userBaseURL + '/sse/subscribe', {
1017
- headers,
1018
- heartbeatTimeout: 60000,
1019
- });
1020
- this.eventSource.onopen = () => {
1021
- this.logger('info', 'client:connectToSSE() - SSE connection established', {});
1022
- };
1023
- this.eventSource.onmessage = (event) => {
1024
- const data = JSON.parse(event.data);
1025
-
1026
- this.logger('info', `client:connectToSSE() - SSE message received event : ${JSON.stringify(data)}`, { event });
1027
-
1028
- if (data.type === 'AccountUserChainProjects') {
1029
- let user: UserResponse = {
1030
- name: data.name,
1031
- id: data.id,
1032
- avatar: data.avatar,
1033
- about_me: data.about_me,
1034
- project_id: data.project_id,
1035
- };
1036
-
1037
- if (this.user?.id === user.id) {
1038
- this.user = { ...this.user, ...user };
1039
- }
1040
-
1041
- this.state.updateUser(user);
1042
-
1043
- const userInfo = {
1044
- id: user.id,
1045
- name: user.name ? user.name : user.id,
1046
- avatar: user?.avatar || '',
1047
- };
1048
-
1049
- this._updateMemberWatcherReferences(userInfo);
1050
- this._updateUserMessageReferences(userInfo);
1051
-
1052
- Object.values(this.activeChannels).forEach((channel) => {
1053
- if (channel.data?.type === 'messaging' && Object.keys(channel.state.members).length === 2) {
1054
- const otherMember = Object.values(channel.state.members).find((member) => member.user?.id !== this.userID);
1055
- if (otherMember && otherMember.user?.id === user.id) {
1056
- // Cập nhật tên và avatar channel theo user vừa đổi thông tin
1057
- channel.data.name = user.name || user.id;
1058
- channel.data.image = user.avatar || '';
1059
- }
1060
- }
1061
- });
1062
-
1063
- if (onCallBack) {
1064
- onCallBack(data);
1065
- }
1066
- }
1067
- };
1068
- this.eventSource.onerror = (event: any) => {
1069
- this.logger('error', `client:connectToSSE() - SSE connection error : ${JSON.stringify(event.data)} `, { event });
1070
- if (event.status === 401) {
1071
- this.logger('error', 'client:connectToSSE() - Unauthorized (401). Aborting the connection.', {});
1072
- this.disconnectFromSSE();
1073
- } else if (
1074
- this.eventSource?.readyState === EventSourcePolyfill.CLOSED ||
1075
- this.eventSource?.readyState === EventSourcePolyfill.CONNECTING
1076
- ) {
1077
- this.eventSource.close();
1078
- setTimeout(() => {
1079
- this.logger('info', 'client:connectToSSE() - Reconnecting to SSE', {});
1080
- this.connectToSSE(onCallBack);
1081
- }, 3000);
1082
- }
1083
- };
1084
- }
1085
- public async disconnectFromSSE(): Promise<void> {
1086
- if (this.eventSource) {
1087
- this.eventSource.close();
1088
- this.eventSource = null;
1089
- this.logger('info', 'client:disconnectFromSSE() - SSE connection closed', {});
1090
- } else {
1091
- this.logger('info', 'client:disconnectFromSSE() - SSE connection already closed', {});
1092
- }
1093
- }
1094
-
1095
- async queryUsers(page_size?: string, page?: number): Promise<UsersResponse> {
1096
- const defaultOptions = {
1097
- presence: false,
1098
- };
1099
-
1100
- // Make sure we wait for the connect promise if there is a pending one
1101
- await this.wsPromise;
1102
-
1103
- let project_id = this.projectId;
1104
- // Return a list of users
1105
- const data = await this.get<UsersResponse>(this.userBaseURL + '/users', {
1106
- project_id,
1107
- page,
1108
- page_size,
1109
- });
1110
-
1111
- this.state.updateUsers(data.data);
1112
-
1113
- return data;
1114
- }
1115
-
1116
- async queryUser(user_id: string): Promise<UserResponse<ErmisChatGenerics>> {
1117
- const project_id = this.projectId;
1118
-
1119
- const userResponse = await this.get<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/' + user_id, {
1120
- project_id,
1121
- });
1122
-
1123
- this.state.updateUser(userResponse);
1124
- return userResponse;
1125
- }
1126
-
1127
- async getBatchUsers(users: string[], page?: number, page_size?: number) {
1128
- let project_id = this.projectId;
1129
-
1130
- const usersRepsonse = await this.post<UsersResponse>(
1131
- this.userBaseURL + '/users/batch?page=1&page_size=10000',
1132
- { users, project_id },
1133
- { page, page_size },
1134
- );
1135
-
1136
- this.state.updateUsers(usersRepsonse.data);
1137
-
1138
- return usersRepsonse.data || [];
1139
- }
1140
-
1141
- async searchUsers(page: number, page_size: number, name?: string): Promise<UsersResponse> {
1142
- let project_id = this.projectId;
1143
-
1144
- const usersResponse = await this.post<UsersResponse>(this.userBaseURL + '/users/search', undefined, {
1145
- page,
1146
- page_size,
1147
- name,
1148
- project_id,
1149
- });
1150
-
1151
- // this.state.updateUsers(usersResponse.data);
1152
-
1153
- return usersResponse;
1154
- }
1155
-
1156
- async queryContacts(): Promise<ContactResult> {
1157
- let project_id = this.projectId;
1158
- const contactResponse = await this.post<ContactResponse>(this.baseURL + '/contacts/list', { project_id });
1159
- const userIds = contactResponse.project_id_user_ids[project_id];
1160
- const contact_users: UserResponse<ErmisChatGenerics>[] = [];
1161
- const block_users: UserResponse<ErmisChatGenerics>[] = [];
1162
-
1163
- userIds.forEach((contact: Contact) => {
1164
- const userID = contact.other_id;
1165
- const state_user = this.state.users[userID];
1166
- const user = state_user ? state_user : { id: userID };
1167
- switch (contact.relation_status) {
1168
- case 'blocked':
1169
- block_users.push(user);
1170
- break;
1171
- case 'normal':
1172
- contact_users.push(user);
1173
- break;
1174
- default:
1175
- }
1176
- });
1177
-
1178
- return {
1179
- contact_users,
1180
- block_users,
1181
- };
1182
- }
1183
-
1184
- _updateProjectID(project_id: string) {
1185
- this.projectId = project_id;
1186
- }
1187
-
1188
- /**
1189
- * Uploads a new avatar image for the current user.
1190
- * The user's avatar URL is automatically updated in both the client and the local state.
1191
- *
1192
- * @param file - The image file to upload.
1193
- * @returns The response containing the new avatar URL.
1194
- */
1195
- async uploadAvatar(file: File) {
1196
- const formData = new FormData();
1197
- formData.append('avatar', file);
1198
- let response = await this.post<{ avatar: string }>(this.userBaseURL + '/users/upload', formData, {
1199
- headers: {
1200
- 'Content-Type': 'multipart/form-data',
1201
- },
1202
- });
1203
- if (this.user) {
1204
- this.user.avatar = response.avatar;
1205
- const new_user = { ...this.user, avatar: response.avatar };
1206
- this.state.updateUser(new_user);
1207
- }
1208
-
1209
- return response;
1210
- }
1211
- async updateProfile(updates: Partial<UserResponse<ErmisChatGenerics>>) {
1212
- let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', updates);
1213
- this.user = response;
1214
- this.state.updateUser(response);
1215
- return response;
1216
- }
1217
-
1218
- /**
1219
- * Queries the API for a list of channels based on provided search filters and sort conditions.
1220
- * Also hydrates these channels into the local SDK state memory.
1221
- *
1222
- * @param filterConditions - Specific criteria to filter channels (e.g. `{ type: 'messaging', members: { $in: ['user1'] } }`).
1223
- * @param sort - The sorting hierarchy applied to the channel results.
1224
- * @param options - Pagination and message limit parameters.
1225
- * @param stateOptions - Defines whether to skip state initialization or offline usage.
1226
- * @returns An array of hydrated and locally manageable `Channel` objects.
1227
- */
1228
- async queryChannels(
1229
- filterConditions: ChannelFilters,
1230
- sort: ChannelSort = [],
1231
- options: { message_limit?: number } = {},
1232
- stateOptions: ChannelStateOptions = {},
1233
- ) {
1234
- // Make sure we wait for the connect promise if there is a pending one
1235
- await this.wsPromise;
1236
-
1237
- let project_id = this.projectId;
1238
-
1239
- // Return a list of channels
1240
- const payload = {
1241
- filter_conditions: { ...filterConditions, project_id },
1242
- sort,
1243
- ...options,
1244
- };
1245
-
1246
- const data = await this.post<QueryChannelsAPIResponse<ErmisChatGenerics>>(this.baseURL + '/channels', payload);
1247
-
1248
- // Sort channels by latest message created_at (including topics if present)
1249
- data.channels.sort((a, b) => {
1250
- // Get latest message created_at in channel a
1251
- let aLatest = getLatestCreatedAt(a.messages);
1252
-
1253
- // If channel a has topics, check messages in topics
1254
- if (a.channel.type === 'team' && Array.isArray(a.topics)) {
1255
- for (const topic of a.topics) {
1256
- aLatest = Math.max(aLatest, getLatestCreatedAt(topic.messages));
1257
- }
1258
- }
1259
-
1260
- // Get latest message created_at in channel b
1261
- let bLatest = getLatestCreatedAt(b.messages);
1262
-
1263
- // If channel b has topics, check messages in topics
1264
- if (b.channel.type === 'team' && Array.isArray(b.topics)) {
1265
- for (const topic of b.topics) {
1266
- bLatest = Math.max(bLatest, getLatestCreatedAt(topic.messages));
1267
- }
1268
- }
1269
-
1270
- // Descending order (newest first)
1271
- return bLatest - aLatest;
1272
- });
1273
-
1274
- const memberIds =
1275
- Array.from(
1276
- new Set(data.channels.flatMap((c) => (c.channel.members || []).map((member: any) => member.user.id))),
1277
- ) || [];
1278
-
1279
- const membersInfo = filterConditions.parent_cid
1280
- ? Object.values(this.state.users)
1281
- : await this.getBatchUsers(memberIds);
1282
- data.channels.forEach((c) => {
1283
- c.channel.members = enrichWithUserInfo(c.channel.members, membersInfo);
1284
- c.messages = enrichWithUserInfo(c.messages, membersInfo);
1285
- c.read = enrichWithUserInfo(c.read || [], membersInfo);
1286
- c.channel.name =
1287
- c.channel.type === 'messaging' ? getDirectChannelName(c.channel.members, this.userID || '') : c.channel.name;
1288
- c.channel.image =
1289
- c.channel.type === 'messaging' ? getDirectChannelImage(c.channel.members, this.userID || '') : c.channel.image;
1290
-
1291
- if (c.channel.type === 'team' && Array.isArray(c.topics)) {
1292
- c.topics.sort((a, b) => {
1293
- const aLatest = getLatestCreatedAt(a.messages);
1294
- const bLatest = getLatestCreatedAt(b.messages);
1295
- return bLatest - aLatest;
1296
- });
1297
- }
1298
-
1299
- if (c.pinned_messages) {
1300
- c.pinned_messages = enrichWithUserInfo(c.pinned_messages || [], membersInfo);
1301
- }
1302
- });
1303
-
1304
- const { channels, userIds } = this.hydrateChannels(data.channels, stateOptions);
1305
-
1306
- // if (userIds.length > 0) {
1307
- // await this.getBatchUsers(userIds);
1308
- // }
1309
-
1310
- console.log('---channels---', channels);
1311
-
1312
- return channels;
1313
- }
1314
-
1315
- hydrateChannels(
1316
- channelsFromApi: ChannelAPIResponse<ErmisChatGenerics>[] = [],
1317
- stateOptions: ChannelStateOptions = {},
1318
- ) {
1319
- const { skipInitialization, offlineMode = false } = stateOptions;
1320
-
1321
- const channels: Channel<ErmisChatGenerics>[] = [];
1322
- const userIds: string[] = [];
1323
- for (const channelState of channelsFromApi) {
1324
- const c = this.channel(channelState.channel.type, channelState.channel.id);
1325
- c.data = { ...channelState.channel, is_pinned: channelState.is_pinned || false };
1326
- c.offlineMode = offlineMode;
1327
- c.initialized = !offlineMode;
1328
-
1329
- if (skipInitialization === undefined) {
1330
- c._initializeState(channelState, 'latest', (id) => {
1331
- if (!userIds.includes(id)) {
1332
- userIds.push(id);
1333
- }
1334
- });
1335
- } else if (!skipInitialization.includes(channelState.channel.id)) {
1336
- c.state.clearMessages();
1337
- c._initializeState(channelState, 'latest', (id) => {
1338
- if (!userIds.includes(id)) {
1339
- userIds.push(id);
1340
- }
1341
- });
1342
- }
1343
-
1344
- channels.push(c);
1345
- }
1346
-
1347
- // const sortedChannels = channels.sort((a: any, b: any) => {
1348
- // const aTime = a.state.last_message_at
1349
- // ? new Date(a.state.last_message_at).getTime()
1350
- // : a.data.created_at
1351
- // ? new Date(a.data.created_at).getTime()
1352
- // : 0;
1353
- // const bTime = b.state.last_message_at
1354
- // ? new Date(b.state.last_message_at).getTime()
1355
- // : b.data.created_at
1356
- // ? new Date(b.data.created_at).getTime()
1357
- // : 0;
1358
- // return bTime - aTime; // Descending order
1359
- // });
1360
-
1361
- // ensure we have the users for all the channels we just added
1362
-
1363
- return { channels, userIds };
1364
- }
1365
-
1366
- async searchPublicChannel(search_term: string, offset = 0, limit = 25) {
1367
- let project_id = this.projectId;
1368
-
1369
- return await this.post<APIResponse>(this.baseURL + `/channels/public/search`, {
1370
- project_id,
1371
- search_term,
1372
- limit: limit,
1373
- offset: offset,
1374
- });
1375
- }
1376
-
1377
- async pinChannel(channelType: string, channelId: string) {
1378
- return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/pin`);
1379
- }
1380
-
1381
- async unpinChannel(channelType: string, channelId: string) {
1382
- return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/unpin`);
1383
- }
1384
-
1385
- /**
1386
- * Creates or instantiates an interactive `Channel` object locally based on type and custom data.
1387
- * This does NOT immediately ping the API unless `channel.watch()` or `channel.create()` is subsequently called.
1388
- *
1389
- * @param type - The strict channel type descriptor (e.g., `'messaging'`, `'team'`, `'livestream'`).
1390
- * @param custom - Initial metadata or specific members to include in the channel.
1391
- * @returns A newly instantiated `Channel` object.
1392
- */
1393
- channel(type: string, custom?: ChannelData<ErmisChatGenerics>): Channel<ErmisChatGenerics>;
1394
- /**
1395
- * Creates or instantiates an interactive `Channel` object locally using a specific ID.
1396
- *
1397
- * @param type - The strict channel type descriptor.
1398
- * @param id - The unique identifer (UUID / slug) for the channel.
1399
- * @param custom - Initial metadata or specific members to include in the channel.
1400
- * @returns A newly instantiated `Channel` object.
1401
- */
1402
- channel(type: string, id: string, custom?: ChannelData<ErmisChatGenerics>): Channel<ErmisChatGenerics>;
1403
- channel(
1404
- channelType: string,
1405
- channelIDOrCustom?: string | ChannelData<ErmisChatGenerics>,
1406
- custom?: ChannelData<ErmisChatGenerics>,
1407
- ): Channel<ErmisChatGenerics> {
1408
- if (!this.userID) {
1409
- throw Error('Call connectUser before creating a channel');
1410
- }
1411
-
1412
- if (~channelType.indexOf(':')) {
1413
- throw Error(`Invalid channel group ${channelType}, can't contain the : character`);
1414
- }
1415
-
1416
- let channelID: string | undefined = undefined;
1417
- let customData = custom || ({} as ChannelData<ErmisChatGenerics>);
1418
-
1419
- if (typeof channelIDOrCustom === 'string') {
1420
- channelID = channelIDOrCustom;
1421
- } else if (typeof channelIDOrCustom === 'object' && channelIDOrCustom !== null) {
1422
- customData = channelIDOrCustom as ChannelData<ErmisChatGenerics>;
1423
- }
1424
-
1425
- return this.getChannelById(channelType, channelID, customData);
1426
- }
1427
-
1428
- getChannelById = (channelType: string, channelID: string | undefined, custom: ChannelData<ErmisChatGenerics>) => {
1429
- const cid = `${channelType}:${channelID || ''}`;
1430
- if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1431
- const channel = this.activeChannels[cid];
1432
- if (Object.keys(custom).length > 0) {
1433
- channel.data = custom;
1434
- channel._data = custom;
1435
- }
1436
- return channel;
1437
- }
1438
- const channel = new Channel<ErmisChatGenerics>(this, channelType, channelID, custom);
1439
- this.activeChannels[channel.cid] = channel;
1440
-
1441
- return channel;
1442
- };
1443
-
1444
- getChannel = (channelType: string, custom: ChannelData<ErmisChatGenerics>) => {
1445
- const uuid = randomId();
1446
- const id = `${this.projectId}:${uuid}`;
1447
- // only allow 1 channel object per cid
1448
- const cid = `${channelType}:${id}`;
1449
- if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1450
- const channel = this.activeChannels[cid];
1451
- if (Object.keys(custom).length > 0) {
1452
- channel.data = custom;
1453
- channel._data = custom;
1454
- }
1455
- return channel;
1456
- }
1457
- const channel = new Channel<ErmisChatGenerics>(this, channelType, id, custom);
1458
- this.activeChannels[channel.cid] = channel;
1459
-
1460
- return channel;
1461
- };
1462
-
1463
- /**
1464
- * Creates a quick channel and immediately registers it on the server.
1465
- * Quick channels are public group channels that anyone can join without an invitation.
1466
- * The creator is added as the first member automatically.
1467
- *
1468
- * @param name - An optional display name for the channel.
1469
- * @returns A promise that resolves to the created `Channel` object.
1470
- */
1471
- async createQuickChannel(name?: string): Promise<Channel<ErmisChatGenerics>> {
1472
- if (!this.userID) {
1473
- throw Error('Call connectUser before creating a channel');
1474
- }
1475
-
1476
- const now = new Date();
1477
- const formattedDate = new Intl.DateTimeFormat('en-US', {
1478
- month: 'short', day: '2-digit', year: 'numeric',
1479
- hour: '2-digit', minute: '2-digit', hour12: false,
1480
- }).format(now);
1481
-
1482
- const payload = {
1483
- name: name || `Quick Channel - ${formattedDate}`,
1484
- members: [this.userID],
1485
- public: true,
1486
- } as unknown as ChannelData<ErmisChatGenerics>;
1487
-
1488
- const quickChannel = this.channel('meeting', payload);
1489
- await quickChannel.create();
1490
-
1491
- return quickChannel;
1492
- }
1493
-
1494
- /**
1495
- * Joins a quick channel by its ID.
1496
- * Automatically checks whether the caller is already a member.
1497
- * If not, it joins the channel and synchronizes state.
1498
- *
1499
- * @param channelId - The ID of the quick channel to join.
1500
- * @returns A promise that resolves to the joined `Channel` object.
1501
- */
1502
- async joinQuickChannel(channelId: string): Promise<Channel<ErmisChatGenerics>> {
1503
- if (!this.userID) {
1504
- throw Error('Call connectUser before joining a channel');
1505
- }
1506
-
1507
- const quickChannel = this.channel('meeting', channelId);
1508
- await quickChannel.watch();
1509
-
1510
- const isMember = quickChannel.state.members && quickChannel.state.members[this.userID];
1511
-
1512
- if (!isMember) {
1513
- await quickChannel.acceptInvite('join');
1514
- await quickChannel.watch();
1515
- }
1516
-
1517
- return quickChannel;
1518
- }
1519
-
1520
- _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) {
1521
- let pinExpires: null | string = null;
1522
- if (typeof timeoutOrExpirationDate === 'number') {
1523
- const now = new Date();
1524
- now.setSeconds(now.getSeconds() + timeoutOrExpirationDate);
1525
- pinExpires = now.toISOString();
1526
- } else if (isString(timeoutOrExpirationDate)) {
1527
- pinExpires = timeoutOrExpirationDate;
1528
- } else if (timeoutOrExpirationDate instanceof Date) {
1529
- pinExpires = timeoutOrExpirationDate.toISOString();
1530
- }
1531
- return pinExpires;
1532
- }
1533
-
1534
- getUserAgent() {
1535
- return (
1536
- this.userAgent || `ermis-chat-sdk-javascript-client-${this.node ? 'node' : 'browser'}-${process.env.PKG_VERSION}`
1537
- );
1538
- }
1539
-
1540
- setUserAgent(userAgent: string) {
1541
- this.userAgent = userAgent;
1542
- }
1543
-
1544
- _enrichAxiosOptions(
1545
- options: AxiosRequestConfig & { config?: AxiosRequestConfig } = {
1546
- params: {},
1547
- headers: {},
1548
- config: {},
1549
- },
1550
- ): AxiosRequestConfig {
1551
- let token = this._getToken();
1552
-
1553
- if (!token?.startsWith('Bearer ')) {
1554
- token = `Bearer ${token}`;
1555
- }
1556
-
1557
- const authorization = token ? { Authorization: token } : undefined;
1558
-
1559
- if (!options.headers?.['x-client-request-id']) {
1560
- options.headers = {
1561
- ...options.headers,
1562
- 'x-client-request-id': randomId(),
1563
- };
1564
- }
1565
- const {
1566
- params: axiosRequestConfigParams,
1567
- headers: axiosRequestConfigHeaders,
1568
- ...axiosRequestConfigRest
1569
- } = this.options.axiosRequestConfig || {};
1570
-
1571
- let user_service_params = {
1572
- ...options.params,
1573
- ...(axiosRequestConfigParams || {}),
1574
- };
1575
-
1576
- return {
1577
- params: user_service_params,
1578
- headers: {
1579
- ...authorization,
1580
- 'stream-auth-type': this.getAuthType(),
1581
- 'X-Stream-Client': this.getUserAgent(),
1582
- ...options.headers,
1583
- ...(axiosRequestConfigHeaders || {}),
1584
- },
1585
-
1586
- ...options.config,
1587
- ...(axiosRequestConfigRest || {}),
1588
- };
1589
- }
1590
-
1591
- _getToken() {
1592
- if (!this.tokenManager) return null;
1593
-
1594
- return this.tokenManager.getToken();
1595
- }
1596
-
1597
- _startCleaning() {
1598
- const that = this;
1599
- if (this.cleaningIntervalRef != null) {
1600
- return;
1601
- }
1602
- this.cleaningIntervalRef = setInterval(() => {
1603
- // call clean on the channel, used for calling the stop.typing event etc.
1604
- for (const channel of Object.values(that.activeChannels)) {
1605
- channel.clean();
1606
- }
1607
- }, 500);
1608
- }
1609
-
1610
- _buildWSPayload = (client_request_id?: string) => {
1611
- return JSON.stringify({
1612
- user_id: this.userID,
1613
- user_details: this.user,
1614
- client_request_id,
1615
- });
1616
- };
1617
- }