@ermis-network/ermis-chat-sdk 2.0.0 → 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/dist/encryption/index.browser.cjs +13045 -0
  3. package/dist/encryption/index.browser.cjs.map +1 -0
  4. package/dist/encryption/index.browser.mjs +12959 -0
  5. package/dist/encryption/index.browser.mjs.map +1 -0
  6. package/dist/encryption/index.cjs +13045 -0
  7. package/dist/encryption/index.cjs.map +1 -0
  8. package/dist/encryption/index.d.mts +3 -0
  9. package/dist/encryption/index.d.ts +3 -0
  10. package/dist/encryption/index.mjs +12959 -0
  11. package/dist/encryption/index.mjs.map +1 -0
  12. package/dist/index-CcvHIY5q.d.mts +4988 -0
  13. package/dist/index-CcvHIY5q.d.ts +4988 -0
  14. package/dist/index.browser.cjs +20192 -5766
  15. package/dist/index.browser.cjs.map +1 -1
  16. package/dist/index.browser.full-bundle.min.js +20 -16
  17. package/dist/index.browser.full-bundle.min.js.map +1 -1
  18. package/dist/index.browser.mjs +20106 -5731
  19. package/dist/index.browser.mjs.map +1 -1
  20. package/dist/index.cjs +20191 -5765
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.mts +15 -1337
  23. package/dist/index.d.ts +15 -1337
  24. package/dist/index.mjs +20106 -5731
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/wasm_worker.worker.mjs +8 -4
  27. package/dist/wasm_worker.worker.mjs.map +1 -1
  28. package/package.json +21 -6
  29. package/public/e2ee-media-stream-worker.js +627 -0
  30. package/public/openmls_wasm_bg.wasm +0 -0
  31. package/src/attachment_utils.ts +0 -148
  32. package/src/auth.ts +0 -352
  33. package/src/channel.ts +0 -1879
  34. package/src/channel_state.ts +0 -612
  35. package/src/client.ts +0 -1759
  36. package/src/client_state.ts +0 -55
  37. package/src/connection.ts +0 -587
  38. package/src/ermis_call_node.ts +0 -1046
  39. package/src/errors.ts +0 -60
  40. package/src/events.ts +0 -46
  41. package/src/hevc_decoder_config.ts +0 -305
  42. package/src/index.ts +0 -17
  43. package/src/media_stream_receiver.ts +0 -593
  44. package/src/media_stream_sender.ts +0 -465
  45. package/src/shims/empty.ts +0 -1
  46. package/src/signal_message.ts +0 -171
  47. package/src/system_message.ts +0 -259
  48. package/src/token_manager.ts +0 -48
  49. package/src/types.ts +0 -594
  50. package/src/utils.ts +0 -553
  51. package/src/wasm/ermis_call_node_wasm.d.ts +0 -156
  52. package/src/wasm/ermis_call_node_wasm.js +0 -1568
  53. package/src/wasm_worker.ts +0 -219
  54. package/src/wasm_worker_proxy.ts +0 -244
package/src/client.ts DELETED
@@ -1,1759 +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({
296
- id: connectionUser.id,
297
- name: connectionUser?.name || connectionUser.id,
298
- avatar: connectionUser?.avatar || '',
299
- });
300
-
301
- const wsPromise = this.openConnection();
302
-
303
- this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
304
- (result) => result[1], // We only return connection promise;
305
- );
306
-
307
- try {
308
- const result = await this.setUserPromise;
309
- // Call SSE after successful connect
310
- await this.connectToSSE();
311
-
312
- // Automatically fetch full profile asynchronously and dispatch event
313
- this.queryUser(connectionUser.id)
314
- .then((fullProfile) => {
315
- this.user = { ...this.user, ...fullProfile };
316
- this.state.updateUser(this.user);
317
- this.dispatchEvent({
318
- type: 'user.updated',
319
- me: this.user,
320
- } as unknown as Event<ErmisChatGenerics>);
321
- })
322
- .catch((err) => {
323
- this.logger('error', 'client:connectUser() - failed to fetch full user profile', { err });
324
- });
325
-
326
- return result;
327
- } catch (err) {
328
- this.disconnectUser();
329
- throw err;
330
- }
331
- };
332
-
333
- setUser = this.connectUser;
334
-
335
- _setToken = (user: UserResponse<ErmisChatGenerics>, userTokenOrProvider: string | null) =>
336
- this.tokenManager.setTokenOrProvider(userTokenOrProvider, user);
337
-
338
- _setUser(user: UserResponse<ErmisChatGenerics>) {
339
- this.user = { ...user };
340
- this.userID = user.id;
341
- }
342
-
343
- closeConnection = async (timeout?: number) => {
344
- if (this.cleaningIntervalRef != null) {
345
- clearInterval(this.cleaningIntervalRef);
346
- this.cleaningIntervalRef = undefined;
347
- }
348
-
349
- await this.wsConnection?.disconnect(timeout);
350
- return Promise.resolve();
351
- };
352
-
353
- openConnection = async () => {
354
- if (!this.userID) {
355
- throw Error('User is not set on client, use client.connectUser instead');
356
- }
357
-
358
- if (this.wsConnection?.isConnecting && this.wsPromise) {
359
- this.logger('info', 'client:openConnection() - connection already in progress', {
360
- tags: ['connection', 'client'],
361
- });
362
- return this.wsPromise;
363
- }
364
-
365
- if (this.wsConnection?.isHealthy) {
366
- this.logger('info', 'client:openConnection() - openConnection called twice, healthy connection already exists', {
367
- tags: ['connection', 'client'],
368
- });
369
-
370
- return Promise.resolve();
371
- }
372
-
373
- this.clientID = `${this.userID}--${randomId()}`;
374
- this.wsPromise = this.connect();
375
- this._startCleaning();
376
- return this.wsPromise;
377
- };
378
-
379
- _setupConnection = this.openConnection;
380
-
381
- /**
382
- * Gracefully disconnects the current user, terminates the WebSocket connection,
383
- * cleans up listeners, and resets the client's internal references.
384
- *
385
- * @param timeout - Optional timeout in milliseconds before forcing the disconnect.
386
- */
387
- disconnectUser = async (timeout?: number) => {
388
- this.logger('info', 'client:disconnect() - Disconnecting the client', {
389
- tags: ['connection', 'client'],
390
- });
391
-
392
- // remove the user specific fields
393
- delete this.user;
394
- delete this.userID;
395
-
396
- const closePromise = this.closeConnection(timeout);
397
-
398
- for (const channel of Object.values(this.activeChannels)) {
399
- channel._disconnect();
400
- }
401
- // ensure we no longer return inactive channels
402
- this.activeChannels = {};
403
- // reset client state
404
- this.state = new ClientState();
405
- // reset token manager
406
- setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls
407
-
408
- // close the WS connection
409
- return closePromise;
410
- };
411
-
412
- disconnect = this.disconnectUser;
413
-
414
- /**
415
- * Attaches an event listener to the client connection.
416
- * Listeners can be scoped to specific event types (e.g. `message.new`) or listen to `all` events.
417
- *
418
- * @param callback - The handler invoked when the event is emitted.
419
- * @returns An object containing an `unsubscribe` method to detach the listener.
420
- */
421
- on(callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
422
- /**
423
- * Attaches an event listener filtered by a specific event type.
424
- *
425
- * @param eventType - The specific event name to listen for (e.g., `'notification.message_new'`).
426
- * @param callback - The handler invoked when the event is emitted.
427
- * @returns An object containing an `unsubscribe` method to detach the listener.
428
- */
429
- on(eventType: string, callback: EventHandler<ErmisChatGenerics>): { unsubscribe: () => void };
430
- on(
431
- callbackOrString: EventHandler<ErmisChatGenerics> | string,
432
- callbackOrNothing?: EventHandler<ErmisChatGenerics>,
433
- ): { unsubscribe: () => void } {
434
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
435
- const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
436
- if (!(key in this.listeners)) {
437
- this.listeners[key] = [];
438
- }
439
- this.logger('info', `Attaching listener for ${key} event`, {
440
- tags: ['event', 'client'],
441
- });
442
- this.listeners[key].push(callback);
443
- return {
444
- unsubscribe: () => {
445
- this.logger('info', `Removing listener for ${key} event`, {
446
- tags: ['event', 'client'],
447
- });
448
- this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
449
- },
450
- };
451
- }
452
-
453
- /**
454
- * Detaches a previously registered general event listener.
455
- * @param callback - The original handler reference to remove.
456
- */
457
- off(callback: EventHandler<ErmisChatGenerics>): void;
458
- /**
459
- * Detaches a previously registered event listener scoped to a specific event type.
460
- * @param eventType - The specific event name.
461
- * @param callback - The original handler reference to remove.
462
- */
463
- off(eventType: string, callback: EventHandler<ErmisChatGenerics>): void;
464
- off(callbackOrString: EventHandler<ErmisChatGenerics> | string, callbackOrNothing?: EventHandler<ErmisChatGenerics>) {
465
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
466
- const callback = callbackOrNothing ? callbackOrNothing : (callbackOrString as EventHandler<ErmisChatGenerics>);
467
- if (!(key in this.listeners)) {
468
- this.listeners[key] = [];
469
- }
470
-
471
- this.logger('info', `Removing listener for ${key} event`, {
472
- tags: ['event', 'client'],
473
- });
474
- this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
475
- }
476
-
477
- _logApiRequest(
478
- type: string,
479
- url: string,
480
- data: unknown,
481
- config: AxiosRequestConfig & {
482
- config?: AxiosRequestConfig & { maxBodyLength?: number };
483
- },
484
- ) {
485
- this.logger(
486
- 'info',
487
- `client: ${type} - Request - ${url}- ${JSON.stringify(data)} - ${JSON.stringify(config.params)}`,
488
- {
489
- tags: ['api', 'api_request', 'client'],
490
- url,
491
- payload: data,
492
- config,
493
- },
494
- );
495
- }
496
-
497
- _logApiResponse<T>(type: string, url: string, response: AxiosResponse<T>) {
498
- this.logger('info', `client:${type} - Response - url: ${url} > status ${response.status}`, {
499
- tags: ['api', 'api_response', 'client'],
500
- url,
501
- response,
502
- });
503
- }
504
-
505
- _logApiError(type: string, url: string, error: unknown, options: unknown) {
506
- this.logger(
507
- 'error',
508
- `client:${type} - Error: ${JSON.stringify(error)} - url: ${url} - options: ${JSON.stringify(options)}`,
509
- {
510
- tags: ['api', 'api_response', 'client'],
511
- url,
512
- error,
513
- },
514
- );
515
- }
516
-
517
- doAxiosRequest = async <T>(
518
- type: string,
519
- url: string,
520
- data?: unknown,
521
- options: AxiosRequestConfig & {
522
- config?: AxiosRequestConfig & { maxBodyLength?: number };
523
- } = {},
524
- ): Promise<T> => {
525
- await this.tokenManager.tokenReady();
526
-
527
- const requestConfig = this._enrichAxiosOptions(options);
528
-
529
- try {
530
- let response: AxiosResponse<T>;
531
- this._logApiRequest(type, url, data, requestConfig);
532
- switch (type) {
533
- case 'get':
534
- response = await this.axiosInstance.get(url, requestConfig);
535
- break;
536
- case 'delete':
537
- response = await this.axiosInstance.delete(url, requestConfig);
538
- break;
539
- case 'post':
540
- response = await this.axiosInstance.post(url, data, requestConfig);
541
- break;
542
- case 'postForm':
543
- response = await this.axiosInstance.postForm(url, data, requestConfig);
544
- break;
545
- case 'put':
546
- response = await this.axiosInstance.put(url, data, requestConfig);
547
- break;
548
- case 'patch':
549
- response = await this.axiosInstance.patch(url, data, requestConfig);
550
- break;
551
- case 'options':
552
- response = await this.axiosInstance.options(url, requestConfig);
553
- break;
554
- default:
555
- throw new Error('Invalid request type');
556
- }
557
- this._logApiResponse<T>(type, url, response);
558
- this.consecutiveFailures = 0;
559
- return this.handleResponse(response);
560
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
561
- } catch (e: any /**TODO: generalize error types */) {
562
- e.client_request_id = requestConfig.headers?.['x-client-request-id'];
563
- this._logApiError(type, url, e, options);
564
- this.consecutiveFailures += 1;
565
- if (e.response) {
566
- return this.handleResponse(e.response);
567
- } else {
568
- throw e as AxiosError<APIErrorResponse>;
569
- }
570
- }
571
- };
572
-
573
- get<T>(url: string, params?: AxiosRequestConfig['params']) {
574
- return this.doAxiosRequest<T>('get', url, null, { params });
575
- }
576
-
577
- put<T>(url: string, data?: unknown) {
578
- return this.doAxiosRequest<T>('put', url, data);
579
- }
580
-
581
- post<T>(url: string, data?: unknown, params?: AxiosRequestConfig['params']) {
582
- return this.doAxiosRequest<T>('post', url, data, { params });
583
- }
584
-
585
- patch<T>(url: string, data?: unknown) {
586
- return this.doAxiosRequest<T>('patch', url, data);
587
- }
588
-
589
- delete<T>(url: string, params?: AxiosRequestConfig['params']) {
590
- return this.doAxiosRequest<T>('delete', url, null, { params });
591
- }
592
-
593
- sendFile(
594
- url: string,
595
- uri: string | NodeJS.ReadableStream | Buffer | File,
596
- name?: string,
597
- contentType?: string,
598
- user?: UserResponse<ErmisChatGenerics>,
599
- ) {
600
- const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
601
- if (user != null) data.append('user', JSON.stringify(user));
602
-
603
- return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, {
604
- headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser
605
- config: {
606
- timeout: 0,
607
- maxContentLength: Infinity,
608
- maxBodyLength: Infinity,
609
- },
610
- });
611
- }
612
-
613
- /**
614
- * Downloads a media file as a Blob via the SDK's configured axiosInstance.
615
- * This avoids CORS issues that arise when using `fetch()` directly from the browser,
616
- * because axios is routed through the SDK's authenticated transport layer.
617
- *
618
- * @param url - The full URL of the media file to download.
619
- * @returns A Blob of the file content.
620
- */
621
- async downloadMedia(url: string): Promise<Blob> {
622
- const response = await fetch(url, { cache: 'no-store' });
623
- if (!response.ok) {
624
- throw new Error(`Failed to download media: ${response.statusText}`);
625
- }
626
- return await response.blob();
627
- }
628
-
629
- errorFromResponse(response: AxiosResponse<APIErrorResponse>): ErrorFromResponse<APIErrorResponse> {
630
- let err: ErrorFromResponse<APIErrorResponse>;
631
- err = new ErrorFromResponse(`ErmisChat error HTTP code: ${response.status}`);
632
- if (response.data && response.data.code) {
633
- err = new Error(`ErmisChat error code ${response.data.code}: ${response.data.message}`);
634
- err.code = response.data.code;
635
- }
636
- err.response = response;
637
- err.status = response.status;
638
- return err;
639
- }
640
-
641
- handleResponse<T>(response: AxiosResponse<T>) {
642
- const data = response.data;
643
- if (isErrorResponse(response)) {
644
- throw this.errorFromResponse(response);
645
- }
646
- return data;
647
- }
648
-
649
- dispatchEvent = (event: Event<ErmisChatGenerics>) => {
650
- if (!event.received_at) event.received_at = new Date();
651
-
652
- // If the event is channel.created or channel.topic.created, handle it asynchronously
653
- if (event.type === 'channel.created' || event.type === 'channel.topic.created') {
654
- this._handleChannelCreatedEvent(event).then(() => {
655
- this._afterDispatchEvent(event);
656
- });
657
- } else {
658
- const postListenerCallbacks = this._handleClientEvent(event);
659
-
660
- // channel event handlers
661
- const cid = event.cid;
662
- const channel = cid ? this.activeChannels[cid] : undefined;
663
- if (channel) {
664
- channel._handleChannelEvent(event);
665
- }
666
-
667
- this._callClientListeners(event);
668
-
669
- if (channel) {
670
- channel._callChannelListeners(event);
671
- }
672
-
673
- postListenerCallbacks.forEach((c) => c());
674
- }
675
- };
676
-
677
- _afterDispatchEvent(event: Event<ErmisChatGenerics>) {
678
- const postListenerCallbacks = this._handleClientEvent(event);
679
-
680
- const cid = event.cid;
681
- const channel = cid ? this.activeChannels[cid] : undefined;
682
- if (channel) {
683
- channel._handleChannelEvent(event);
684
- }
685
-
686
- this._callClientListeners(event);
687
-
688
- if (channel) {
689
- channel._callChannelListeners(event);
690
- }
691
-
692
- postListenerCallbacks.forEach((c) => c());
693
- }
694
-
695
- private async _handleChannelCreatedEvent(event: Event<ErmisChatGenerics>) {
696
- const members = event.channel?.members || [];
697
- // Ensure all members' user info are loaded in state.users
698
- await ensureMembersUserInfoLoaded(this, members);
699
-
700
- // Get the latest users after updating
701
- const updatedUsers = Object.values(this.state.users);
702
-
703
- const enrichedMembers = enrichWithUserInfo(members, updatedUsers);
704
- const channelName =
705
- event.channel_type === 'messaging'
706
- ? getDirectChannelName(enrichedMembers, this.userID || '')
707
- : event.channel?.name;
708
- const channel = {
709
- ...event.channel,
710
- members: enrichedMembers,
711
- name: channelName,
712
- };
713
- const channelState: any = {
714
- channel,
715
- members: enrichedMembers,
716
- messages: [],
717
- pinned_messages: [],
718
- };
719
- const c = this.channel(event.channel_type || '', event.channel_id || '');
720
- c.data = channel;
721
- c._initializeState(channelState, 'latest');
722
- }
723
-
724
- handleEvent = (messageEvent: WebSocket.MessageEvent) => {
725
- // dispatch the event to the channel listeners
726
- const jsonString = messageEvent.data as string;
727
- const event = JSON.parse(jsonString) as Event<ErmisChatGenerics>;
728
- this.dispatchEvent(event);
729
- };
730
-
731
- _updateMemberWatcherReferences = (user: UserResponse<ErmisChatGenerics>) => {
732
- // Iterate through all active channels to ensure we update members even if they haven't sent messages yet
733
- Object.values(this.activeChannels).forEach((channel) => {
734
- if (channel?.state) {
735
- let hasChange = false;
736
- if (channel.state.members[user.id]) {
737
- channel.state.members = {
738
- ...channel.state.members,
739
- [user.id]: {
740
- ...channel.state.members[user.id],
741
- user,
742
- },
743
- };
744
- hasChange = true;
745
-
746
- // Update display name/image for 1-1 Messaging Channels
747
- if (channel.data?.type === 'messaging') {
748
- const members = Object.values(channel.state.members);
749
- if (members.length === 2) {
750
- const otherMember = members.find((m) => m.user?.id !== this.userID);
751
- if (otherMember && otherMember.user?.id === user.id) {
752
- channel.data.name = user.name || user.id;
753
- channel.data.image = user.avatar || '';
754
- }
755
- }
756
- }
757
- }
758
- if (channel.state.watchers[user.id]) {
759
- channel.state.watchers = {
760
- ...channel.state.watchers,
761
- [user.id]: user,
762
- };
763
- hasChange = true;
764
- }
765
- if (channel.state.read[user.id]) {
766
- channel.state.read = {
767
- ...channel.state.read,
768
- [user.id]: {
769
- ...channel.state.read[user.id],
770
- user,
771
- },
772
- };
773
- hasChange = true;
774
- }
775
-
776
- if (hasChange) {
777
- // Trigger channel update for Sidebar and Header
778
- channel._callChannelListeners({
779
- type: 'channel.updated',
780
- channel: channel.data,
781
- cid: channel.cid,
782
- } as any);
783
-
784
- // Trigger member update specifically for Member List components
785
- if (channel.state.members[user.id]) {
786
- channel._callChannelListeners({
787
- type: 'member.updated',
788
- member: channel.state.members[user.id],
789
- cid: channel.cid,
790
- } as any);
791
- }
792
-
793
- // Trigger general user update at channel level
794
- channel._callChannelListeners({
795
- type: 'user.updated',
796
- user: user,
797
- cid: channel.cid,
798
- } as any);
799
- }
800
- }
801
- });
802
- };
803
-
804
- _updateUserReferences = this._updateMemberWatcherReferences;
805
-
806
- _updateUserMessageReferences = (user: UserResponse<ErmisChatGenerics>) => {
807
- const refMap = this.state.userChannelReferences[user.id] || {};
808
-
809
- for (const channelID in refMap) {
810
- const channel = this.activeChannels[channelID];
811
- if (!channel) continue;
812
-
813
- const state = channel.state;
814
-
815
- /** Update the message objects from this user in the state. */
816
- state?.updateUserMessages(user);
817
-
818
- // Trigger re-render for message list components
819
- channel._callChannelListeners({
820
- type: 'channel.updated',
821
- channel: channel.data,
822
- cid: channel.cid,
823
- } as any);
824
-
825
- // Force MessageList refresh by dispatching an update for the last message
826
- const lastMessage = state?.messages[state.messages.length - 1];
827
- if (lastMessage) {
828
- channel._callChannelListeners({
829
- type: 'message.updated',
830
- message: lastMessage,
831
- cid: channel.cid,
832
- } as any);
833
- }
834
- }
835
- };
836
-
837
- _deleteUserMessageReference = (user: UserResponse<ErmisChatGenerics>, hardDelete = false) => {
838
- const refMap = this.state.userChannelReferences[user.id] || {};
839
-
840
- for (const channelID in refMap) {
841
- const channel = this.activeChannels[channelID];
842
- const state = channel.state;
843
-
844
- /** deleted the messages from this user. */
845
- state?.deleteUserMessages(user, hardDelete);
846
- }
847
- };
848
-
849
- _handleClientEvent(event: Event<ErmisChatGenerics>) {
850
- const client = this;
851
- const postListenerCallbacks = [];
852
- this.logger('info', `client:_handleClientEvent - Received event of type { ${event.type} }`, {
853
- tags: ['event', 'client'],
854
- event,
855
- });
856
-
857
- if (event.type === 'health.check' && event.me) {
858
- }
859
-
860
- if ((event.type === 'channel.deleted' || event.type === 'notification.channel_deleted') && event.cid) {
861
- client.state.deleteAllChannelReference(event.cid);
862
- this.activeChannels[event.cid]?._disconnect();
863
-
864
- postListenerCallbacks.push(() => {
865
- if (!event.cid) return;
866
-
867
- delete this.activeChannels[event.cid];
868
- });
869
-
870
- for (const channel of Object.values(this.activeChannels)) {
871
- if (channel.type === 'team' && channel.state.topics?.some((t) => t.cid === event.cid)) {
872
- // Remove the topic with matching cid from the topics array
873
- channel.state.topics = channel.state.topics.filter((t) => t.cid !== event.cid);
874
- }
875
- }
876
- }
877
- if (event.type === 'notification.invite_rejected') {
878
- if (event.member?.user_id === this.userID && event.cid) {
879
- client.state.deleteAllChannelReference(event.cid);
880
- this.activeChannels[event.cid]?._disconnect();
881
-
882
- postListenerCallbacks.push(() => {
883
- if (!event.cid) return;
884
-
885
- delete this.activeChannels[event.cid];
886
- });
887
- }
888
- }
889
- if (event.type === 'notification.invite_accepted') {
890
- //TODO handle channel list and invited channels here
891
- }
892
-
893
- if (event.type === 'member.added') {
894
- if (event.member?.user_id === this.userID) {
895
- const c = this.channel(event.channel_type || '', event.channel_id || '');
896
- // Gọi watch để lấy đầy đủ thông tin channel từ server
897
- c.watch().catch((err) => {
898
- this.logger('error', 'Failed to watch channel after member.added', { err, event });
899
- });
900
- }
901
- }
902
-
903
- if (event.type === 'message.new' && event.channel_type === 'topic') {
904
- postListenerCallbacks.push(() => {
905
- const parentCid = event.parent_cid || event.channel?.parent_cid;
906
- if (parentCid && this.activeChannels[parentCid]) {
907
- const parentChannel = this.activeChannels[parentCid];
908
- if (parentChannel.state.topics) {
909
- parentChannel.state.topics.sort((a, b) => {
910
- const aLatest = a.state?.latestMessages?.[a.state.latestMessages.length - 1]?.created_at;
911
- const bLatest = b.state?.latestMessages?.[b.state.latestMessages.length - 1]?.created_at;
912
- const aTime = aLatest ? new Date(aLatest).getTime() : 0;
913
- const bTime = bLatest ? new Date(bLatest).getTime() : 0;
914
- return bTime - aTime;
915
- });
916
- parentChannel._callChannelListeners({
917
- ...event,
918
- type: 'channel.updated',
919
- channel: parentChannel.data,
920
- } as any);
921
- }
922
- }
923
- });
924
- }
925
- if (event.type === 'channel.topic.updated') {
926
- postListenerCallbacks.push(() => {
927
- const parentCid = event.parent_cid || event.channel?.parent_cid;
928
- if (parentCid && this.activeChannels[parentCid]) {
929
- const parentChannel = this.activeChannels[parentCid];
930
- if (parentChannel.state?.topics && event.channel) {
931
- const topicIndex = parentChannel.state.topics.findIndex(
932
- (t: any) => t.cid === event.cid || t.channel?.cid === event.cid,
933
- );
934
- if (topicIndex !== -1) {
935
- const t = parentChannel.state.topics[topicIndex] as any;
936
- if (t.data) {
937
- t.data = { ...t.data, ...event.channel };
938
- } else if (t.channel) {
939
- t.channel = { ...t.channel, ...event.channel };
940
- } else {
941
- Object.assign(t, event.channel);
942
- }
943
- }
944
- parentChannel._callChannelListeners({
945
- ...event,
946
- type: 'channel.updated',
947
- channel: parentChannel.data,
948
- } as any);
949
- }
950
- }
951
-
952
- if (event.cid && this.activeChannels[event.cid]) {
953
- const topicChannel = this.activeChannels[event.cid];
954
- if (event.channel) {
955
- topicChannel.data = { ...topicChannel.data, ...event.channel };
956
- topicChannel._callChannelListeners({
957
- ...event,
958
- type: 'channel.updated',
959
- channel: topicChannel.data,
960
- } as any);
961
- }
962
- }
963
- });
964
- }
965
-
966
- if (event.type === 'channel.topic.closed' || event.type === 'channel.topic.reopen') {
967
- postListenerCallbacks.push(() => {
968
- const isClosed = event.type === 'channel.topic.closed';
969
- const parentCid = event.parent_cid;
970
- if (parentCid && this.activeChannels[parentCid]) {
971
- const parentChannel = this.activeChannels[parentCid];
972
- if (parentChannel.state?.topics) {
973
- const topicIndex = parentChannel.state.topics.findIndex(
974
- (t: any) => t.cid === event.cid || t.channel?.cid === event.cid,
975
- );
976
- if (topicIndex !== -1) {
977
- const t = parentChannel.state.topics[topicIndex] as any;
978
- if (t.data) t.data.is_closed_topic = isClosed;
979
- else if (t.channel) t.channel.is_closed_topic = isClosed;
980
- else t.is_closed_topic = isClosed;
981
- }
982
- parentChannel._callChannelListeners({
983
- ...event,
984
- type: 'channel.updated',
985
- channel: parentChannel.data,
986
- } as any);
987
- }
988
- }
989
-
990
- if (event.cid && this.activeChannels[event.cid]) {
991
- const topicChannel = this.activeChannels[event.cid];
992
- if (topicChannel.data) {
993
- topicChannel.data.is_closed_topic = isClosed;
994
- }
995
- topicChannel._callChannelListeners({ ...event, type: 'channel.updated', channel: topicChannel.data } as any);
996
- }
997
- });
998
- }
999
-
1000
- if (event.type === 'connection.recovered') {
1001
- postListenerCallbacks.push(() => {
1002
- // Auto-resend offline failed messages
1003
- Object.values(this.activeChannels).forEach((channel) => {
1004
- if (!channel.state?.messages) return;
1005
- const offlineFailedMsgs = channel.state.messages.filter(
1006
- (m) =>
1007
- m.status === 'failed_offline' &&
1008
- m.user?.id === this.userID &&
1009
- (!m.attachments || m.attachments.length === 0),
1010
- );
1011
- offlineFailedMsgs.forEach((msg) => {
1012
- if (msg.id) {
1013
- channel.retryMessage(msg.id).catch((err) => {
1014
- this.logger('error', `Failed to auto-resend offline message ${msg.id}`, {
1015
- tags: ['offline', 'retry'],
1016
- err,
1017
- });
1018
- });
1019
- }
1020
- });
1021
- });
1022
- });
1023
- }
1024
-
1025
- return postListenerCallbacks;
1026
- }
1027
-
1028
- _callClientListeners = (event: Event<ErmisChatGenerics>) => {
1029
- const client = this;
1030
- // gather and call the listeners
1031
- const listeners: Array<(event: Event<ErmisChatGenerics>) => void> = [];
1032
- if (client.listeners.all) {
1033
- listeners.push(...client.listeners.all);
1034
- }
1035
- if (client.listeners[event.type]) {
1036
- listeners.push(...client.listeners[event.type]);
1037
- }
1038
-
1039
- // call the event and send it to the listeners
1040
- for (const listener of listeners) {
1041
- listener(event);
1042
- }
1043
- };
1044
-
1045
- recoverState = async () => {
1046
- this.logger('info', 'client:recoverState() - Start of recoverState', {
1047
- tags: ['connection'],
1048
- });
1049
-
1050
- const cids = Object.keys(this.activeChannels);
1051
- if (cids.length && this.recoverStateOnReconnect) {
1052
- this.logger('info', `client:recoverState() - Start the querying of ${cids.length} channels`, {
1053
- tags: ['connection', 'client'],
1054
- });
1055
-
1056
- const {
1057
- filter = { type: ['messaging', 'team', 'meeting'] } as ChannelFilters,
1058
- sort = [],
1059
- options = { message_limit: 1 },
1060
- } = this.options.recoveryConfig || {};
1061
- await this.queryChannels(filter, sort, options);
1062
-
1063
- this.logger('info', 'client:recoverState() - Querying channels finished', { tags: ['connection', 'client'] });
1064
- this.dispatchEvent({
1065
- type: 'connection.recovered',
1066
- } as Event<ErmisChatGenerics>);
1067
- } else {
1068
- this.dispatchEvent({
1069
- type: 'connection.recovered',
1070
- } as Event<ErmisChatGenerics>);
1071
- }
1072
-
1073
- this.wsPromise = Promise.resolve();
1074
- this.setUserPromise = Promise.resolve();
1075
- };
1076
-
1077
- async connect() {
1078
- if (!this.userID || !this.user) {
1079
- throw Error('Call connectUser before starting the connection');
1080
- }
1081
- if (!this.wsBaseURL) {
1082
- throw Error('Websocket base url not set');
1083
- }
1084
- if (!this.clientID) {
1085
- throw Error('clientID is not set');
1086
- }
1087
-
1088
- // if (!this.wsConnection && (this.options.warmUp || this.options.enableInsights)) {
1089
- // this._sayHi();
1090
- // }
1091
- // The StableWSConnection handles all the reconnection logic.
1092
- if (this.options.wsConnection && this.node) {
1093
- // Intentionally avoiding adding ts generics on wsConnection in options since its only useful for unit test purpose.
1094
- (this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>).setClient(this);
1095
- this.wsConnection = this.options.wsConnection as unknown as StableWSConnection<ErmisChatGenerics>;
1096
- } else {
1097
- this.wsConnection = new StableWSConnection<ErmisChatGenerics>({
1098
- client: this,
1099
- });
1100
- }
1101
-
1102
- try {
1103
- return await this.wsConnection.connect(this.defaultWSTimeout);
1104
- } catch (err: any) {
1105
- throw err;
1106
- }
1107
- }
1108
- public async connectToSSE(onCallBack?: (data: any) => void): Promise<void> {
1109
- if (this.eventSource) {
1110
- this.logger('info', 'client:connectToSSE() - SSE connection already established', {});
1111
- return;
1112
- }
1113
- let token = this._getToken();
1114
-
1115
- if (!token?.startsWith('Bearer ')) {
1116
- token = `Bearer ${token}`;
1117
- }
1118
- const headers = {
1119
- method: 'GET',
1120
- Authorization: token,
1121
- };
1122
- this.eventSource = new EventSourcePolyfill(this.userBaseURL + '/sse/subscribe', {
1123
- headers,
1124
- heartbeatTimeout: 60000,
1125
- });
1126
- this.eventSource.onopen = () => {
1127
- this.logger('info', 'client:connectToSSE() - SSE connection established', {});
1128
- };
1129
- this.eventSource.onmessage = (event) => {
1130
- const data = JSON.parse(event.data);
1131
-
1132
- this.logger('info', `client:connectToSSE() - SSE message received event : ${JSON.stringify(data)}`, { event });
1133
- if (data.type === 'AccountUserChainProjects') {
1134
- const userInfo: UserResponse<ErmisChatGenerics> = {
1135
- id: data.id,
1136
- name: data.name,
1137
- avatar: data.avatar,
1138
- about_me: data.about_me,
1139
- project_id: data.project_id,
1140
- };
1141
-
1142
- // 1. Update current user info if ID matches
1143
- if (this.user?.id === userInfo.id) {
1144
- this.user = { ...this.user, ...userInfo };
1145
- }
1146
-
1147
- // 2. Update Client State
1148
- this.state.updateUser(userInfo);
1149
-
1150
- const minimalUserInfo = {
1151
- id: userInfo.id,
1152
- name: userInfo.name || userInfo.id,
1153
- avatar: userInfo.avatar || '',
1154
- };
1155
-
1156
- // 3. Update references and trigger re-renders
1157
- this._updateMemberWatcherReferences(minimalUserInfo);
1158
- this._updateUserMessageReferences(minimalUserInfo);
1159
-
1160
- if (onCallBack) {
1161
- onCallBack(data);
1162
- }
1163
-
1164
- this.dispatchEvent({
1165
- type: 'user.updated',
1166
- user: userInfo,
1167
- me: this.user?.id === userInfo.id ? this.user : undefined,
1168
- } as any);
1169
- }
1170
- };
1171
- this.eventSource.onerror = (event: any) => {
1172
- this.logger('error', `client:connectToSSE() - SSE connection error : ${JSON.stringify(event.data)} `, { event });
1173
- if (event.status === 401) {
1174
- this.logger('error', 'client:connectToSSE() - Unauthorized (401). Aborting the connection.', {});
1175
- this.disconnectFromSSE();
1176
- } else if (
1177
- this.eventSource?.readyState === EventSourcePolyfill.CLOSED ||
1178
- this.eventSource?.readyState === EventSourcePolyfill.CONNECTING
1179
- ) {
1180
- this.eventSource.close();
1181
- setTimeout(() => {
1182
- this.logger('info', 'client:connectToSSE() - Reconnecting to SSE', {});
1183
- this.connectToSSE(onCallBack);
1184
- }, 3000);
1185
- }
1186
- };
1187
- }
1188
- public async disconnectFromSSE(): Promise<void> {
1189
- if (this.eventSource) {
1190
- this.eventSource.close();
1191
- this.eventSource = null;
1192
- this.logger('info', 'client:disconnectFromSSE() - SSE connection closed', {});
1193
- } else {
1194
- this.logger('info', 'client:disconnectFromSSE() - SSE connection already closed', {});
1195
- }
1196
- }
1197
-
1198
- async queryUsers(page_size?: string, page?: number): Promise<UsersResponse> {
1199
- const defaultOptions = {
1200
- presence: false,
1201
- };
1202
-
1203
- // Make sure we wait for the connect promise if there is a pending one
1204
- await this.wsPromise;
1205
-
1206
- let project_id = this.projectId;
1207
- // Return a list of users
1208
- const data = await this.get<UsersResponse>(this.userBaseURL + '/users', {
1209
- project_id,
1210
- page,
1211
- page_size,
1212
- });
1213
-
1214
- this.state.updateUsers(data.data);
1215
-
1216
- return data;
1217
- }
1218
-
1219
- async queryUser(user_id: string): Promise<UserResponse<ErmisChatGenerics>> {
1220
- const project_id = this.projectId;
1221
-
1222
- const userResponse = await this.get<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/' + user_id, {
1223
- project_id,
1224
- });
1225
-
1226
- this.state.updateUser(userResponse);
1227
- return userResponse;
1228
- }
1229
-
1230
- async getBatchUsers(users: string[], page?: number, page_size?: number) {
1231
- let project_id = this.projectId;
1232
-
1233
- const usersRepsonse = await this.post<UsersResponse>(
1234
- this.userBaseURL + '/users/batch?page=1&page_size=10000',
1235
- { users, project_id },
1236
- { page, page_size },
1237
- );
1238
-
1239
- this.state.updateUsers(usersRepsonse.data);
1240
-
1241
- return usersRepsonse.data || [];
1242
- }
1243
-
1244
- async searchUsers(page: number, page_size: number, name?: string): Promise<UsersResponse> {
1245
- let project_id = this.projectId;
1246
-
1247
- const usersResponse = await this.post<UsersResponse>(this.userBaseURL + '/users/search', undefined, {
1248
- page,
1249
- page_size,
1250
- name,
1251
- project_id,
1252
- });
1253
-
1254
- // this.state.updateUsers(usersResponse.data);
1255
-
1256
- return usersResponse;
1257
- }
1258
-
1259
- async queryContacts(): Promise<ContactResult> {
1260
- let project_id = this.projectId;
1261
- const contactResponse = await this.post<ContactResponse>(this.baseURL + '/contacts/list', { project_id });
1262
- const userIds = contactResponse.project_id_user_ids[project_id];
1263
- const contact_users: UserResponse<ErmisChatGenerics>[] = [];
1264
- const block_users: UserResponse<ErmisChatGenerics>[] = [];
1265
-
1266
- userIds.forEach((contact: Contact) => {
1267
- const userID = contact.other_id;
1268
- const state_user = this.state.users[userID];
1269
- const user = state_user ? state_user : { id: userID };
1270
- switch (contact.relation_status) {
1271
- case 'blocked':
1272
- block_users.push(user);
1273
- break;
1274
- case 'normal':
1275
- contact_users.push(user);
1276
- break;
1277
- default:
1278
- }
1279
- });
1280
-
1281
- return {
1282
- contact_users,
1283
- block_users,
1284
- };
1285
- }
1286
-
1287
- _updateProjectID(project_id: string) {
1288
- this.projectId = project_id;
1289
- }
1290
-
1291
- /**
1292
- * Uploads a new avatar image for the current user.
1293
- * The user's avatar URL is automatically updated in both the client and the local state.
1294
- *
1295
- * @param file - The image file to upload.
1296
- * @returns The response containing the new avatar URL.
1297
- */
1298
- async uploadAvatar(file: File) {
1299
- const formData = new FormData();
1300
- formData.append('avatar', file);
1301
- let response = await this.post<{ avatar: string }>(this.userBaseURL + '/users/upload', formData, {
1302
- headers: {
1303
- 'Content-Type': 'multipart/form-data',
1304
- },
1305
- });
1306
- if (this.user) {
1307
- this.user.avatar = response.avatar;
1308
- const new_user = { ...this.user, avatar: response.avatar };
1309
- this.state.updateUser(new_user);
1310
-
1311
- const userInfo = {
1312
- id: this.user.id,
1313
- name: this.user.name ? this.user.name : this.user.id,
1314
- avatar: this.user?.avatar || '',
1315
- };
1316
-
1317
- this._updateMemberWatcherReferences(userInfo);
1318
- this._updateUserMessageReferences(userInfo);
1319
-
1320
- this.dispatchEvent({
1321
- type: 'user.updated',
1322
- me: this.user,
1323
- } as unknown as Event<ErmisChatGenerics>);
1324
- }
1325
-
1326
- return response;
1327
- }
1328
- async updateProfile(updates: Partial<UserResponse<ErmisChatGenerics>>) {
1329
- let response = await this.patch<UserResponse<ErmisChatGenerics>>(this.userBaseURL + '/users/update', updates);
1330
- this.user = response;
1331
- this.state.updateUser(response);
1332
-
1333
- if (this.user) {
1334
- const userInfo = {
1335
- id: this.user.id,
1336
- name: this.user.name ? this.user.name : this.user.id,
1337
- avatar: this.user?.avatar || '',
1338
- };
1339
-
1340
- this._updateMemberWatcherReferences(userInfo);
1341
- this._updateUserMessageReferences(userInfo);
1342
-
1343
- this.dispatchEvent({
1344
- type: 'user.updated',
1345
- me: this.user,
1346
- } as unknown as Event<ErmisChatGenerics>);
1347
- }
1348
-
1349
- return response;
1350
- }
1351
-
1352
- /**
1353
- * Queries the API for a list of channels based on provided search filters and sort conditions.
1354
- * Also hydrates these channels into the local SDK state memory.
1355
- *
1356
- * @param filterConditions - Specific criteria to filter channels (e.g. `{ type: 'messaging', members: { $in: ['user1'] } }`).
1357
- * @param sort - The sorting hierarchy applied to the channel results.
1358
- * @param options - Pagination and message limit parameters.
1359
- * @param stateOptions - Defines whether to skip state initialization or offline usage.
1360
- * @returns An array of hydrated and locally manageable `Channel` objects.
1361
- */
1362
- async queryChannels(
1363
- filterConditions: ChannelFilters,
1364
- sort: ChannelSort = [],
1365
- options: { message_limit?: number } = {},
1366
- stateOptions: ChannelStateOptions = {},
1367
- ) {
1368
- // Make sure we wait for the connect promise if there is a pending one
1369
- await this.wsPromise;
1370
-
1371
- let project_id = this.projectId;
1372
-
1373
- // Return a list of channels
1374
- const payload = {
1375
- filter_conditions: { ...filterConditions, project_id },
1376
- sort,
1377
- ...options,
1378
- };
1379
-
1380
- const data = await this.post<QueryChannelsAPIResponse<ErmisChatGenerics>>(this.baseURL + '/channels', payload);
1381
-
1382
- // Sort channels by latest message created_at (including topics if present)
1383
- data.channels.sort((a, b) => {
1384
- // Get latest message created_at in channel a
1385
- let aLatest = getLatestCreatedAt(a.messages);
1386
-
1387
- // If channel a has topics, check messages in topics
1388
- if (a.channel.type === 'team' && Array.isArray(a.topics)) {
1389
- for (const topic of a.topics) {
1390
- aLatest = Math.max(aLatest, getLatestCreatedAt(topic.messages));
1391
- }
1392
- }
1393
-
1394
- // Get latest message created_at in channel b
1395
- let bLatest = getLatestCreatedAt(b.messages);
1396
-
1397
- // If channel b has topics, check messages in topics
1398
- if (b.channel.type === 'team' && Array.isArray(b.topics)) {
1399
- for (const topic of b.topics) {
1400
- bLatest = Math.max(bLatest, getLatestCreatedAt(topic.messages));
1401
- }
1402
- }
1403
-
1404
- // Descending order (newest first)
1405
- return bLatest - aLatest;
1406
- });
1407
-
1408
- const memberIds =
1409
- Array.from(
1410
- new Set(data.channels.flatMap((c) => (c.channel.members || []).map((member: any) => member.user.id))),
1411
- ) || [];
1412
-
1413
- const membersInfo = filterConditions.parent_cid
1414
- ? Object.values(this.state.users)
1415
- : await this.getBatchUsers(memberIds);
1416
- data.channels.forEach((c) => {
1417
- c.channel.members = enrichWithUserInfo(c.channel.members, membersInfo);
1418
- c.messages = enrichWithUserInfo(c.messages, membersInfo);
1419
- c.read = enrichWithUserInfo(c.read || [], membersInfo);
1420
- c.channel.name =
1421
- c.channel.type === 'messaging' ? getDirectChannelName(c.channel.members, this.userID || '') : c.channel.name;
1422
- c.channel.image =
1423
- c.channel.type === 'messaging' ? getDirectChannelImage(c.channel.members, this.userID || '') : c.channel.image;
1424
-
1425
- if (c.channel.type === 'team' && Array.isArray(c.topics)) {
1426
- c.topics.sort((a, b) => {
1427
- const aLatest = getLatestCreatedAt(a.messages);
1428
- const bLatest = getLatestCreatedAt(b.messages);
1429
- return bLatest - aLatest;
1430
- });
1431
- }
1432
-
1433
- if (c.pinned_messages) {
1434
- c.pinned_messages = enrichWithUserInfo(c.pinned_messages || [], membersInfo);
1435
- }
1436
- });
1437
-
1438
- const { channels, userIds } = this.hydrateChannels(data.channels, stateOptions);
1439
-
1440
- // if (userIds.length > 0) {
1441
- // await this.getBatchUsers(userIds);
1442
- // }
1443
-
1444
- console.log('---channels---', channels);
1445
-
1446
- this.dispatchEvent({
1447
- type: 'channels.queried',
1448
- } as unknown as Event<ErmisChatGenerics>);
1449
-
1450
- return channels;
1451
- }
1452
-
1453
- hydrateChannels(
1454
- channelsFromApi: ChannelAPIResponse<ErmisChatGenerics>[] = [],
1455
- stateOptions: ChannelStateOptions = {},
1456
- ) {
1457
- const { skipInitialization, offlineMode = false } = stateOptions;
1458
-
1459
- const channels: Channel<ErmisChatGenerics>[] = [];
1460
- const userIds: string[] = [];
1461
- for (const channelState of channelsFromApi) {
1462
- const c = this.channel(channelState.channel.type, channelState.channel.id);
1463
- c.data = { ...channelState.channel, is_pinned: channelState.is_pinned || false };
1464
- c.offlineMode = offlineMode;
1465
- c.initialized = !offlineMode;
1466
-
1467
- if (skipInitialization === undefined) {
1468
- c._initializeState(channelState, 'latest', (id) => {
1469
- if (!userIds.includes(id)) {
1470
- userIds.push(id);
1471
- }
1472
- });
1473
- } else if (!skipInitialization.includes(channelState.channel.id)) {
1474
- c.state.clearMessages();
1475
- c._initializeState(channelState, 'latest', (id) => {
1476
- if (!userIds.includes(id)) {
1477
- userIds.push(id);
1478
- }
1479
- });
1480
- }
1481
-
1482
- channels.push(c);
1483
- }
1484
-
1485
- // const sortedChannels = channels.sort((a: any, b: any) => {
1486
- // const aTime = a.state.last_message_at
1487
- // ? new Date(a.state.last_message_at).getTime()
1488
- // : a.data.created_at
1489
- // ? new Date(a.data.created_at).getTime()
1490
- // : 0;
1491
- // const bTime = b.state.last_message_at
1492
- // ? new Date(b.state.last_message_at).getTime()
1493
- // : b.data.created_at
1494
- // ? new Date(b.data.created_at).getTime()
1495
- // : 0;
1496
- // return bTime - aTime; // Descending order
1497
- // });
1498
-
1499
- // ensure we have the users for all the channels we just added
1500
-
1501
- return { channels, userIds };
1502
- }
1503
-
1504
- async searchPublicChannel(search_term: string, offset = 0, limit = 25) {
1505
- let project_id = this.projectId;
1506
-
1507
- return await this.post<APIResponse>(this.baseURL + `/channels/public/search`, {
1508
- project_id,
1509
- search_term,
1510
- limit: limit,
1511
- offset: offset,
1512
- });
1513
- }
1514
-
1515
- async pinChannel(channelType: string, channelId: string) {
1516
- return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/pin`);
1517
- }
1518
-
1519
- async unpinChannel(channelType: string, channelId: string) {
1520
- return await this.post<APIResponse>(this.baseURL + `/channels/${channelType}/${channelId}/unpin`);
1521
- }
1522
-
1523
- /**
1524
- * Creates or instantiates an interactive `Channel` object locally based on type and custom data.
1525
- * This does NOT immediately ping the API unless `channel.watch()` or `channel.create()` is subsequently called.
1526
- *
1527
- * @param type - The strict channel type descriptor (e.g., `'messaging'`, `'team'`, `'livestream'`).
1528
- * @param custom - Initial metadata or specific members to include in the channel.
1529
- * @returns A newly instantiated `Channel` object.
1530
- */
1531
- channel(type: string, custom?: ChannelData<ErmisChatGenerics>): Channel<ErmisChatGenerics>;
1532
- /**
1533
- * Creates or instantiates an interactive `Channel` object locally using a specific ID.
1534
- *
1535
- * @param type - The strict channel type descriptor.
1536
- * @param id - The unique identifer (UUID / slug) for the channel.
1537
- * @param custom - Initial metadata or specific members to include in the channel.
1538
- * @returns A newly instantiated `Channel` object.
1539
- */
1540
- channel(type: string, id: string, custom?: ChannelData<ErmisChatGenerics>): Channel<ErmisChatGenerics>;
1541
- channel(
1542
- channelType: string,
1543
- channelIDOrCustom?: string | ChannelData<ErmisChatGenerics>,
1544
- custom?: ChannelData<ErmisChatGenerics>,
1545
- ): Channel<ErmisChatGenerics> {
1546
- if (!this.userID) {
1547
- throw Error('Call connectUser before creating a channel');
1548
- }
1549
-
1550
- if (~channelType.indexOf(':')) {
1551
- throw Error(`Invalid channel group ${channelType}, can't contain the : character`);
1552
- }
1553
-
1554
- let channelID: string | undefined = undefined;
1555
- let customData = custom || ({} as ChannelData<ErmisChatGenerics>);
1556
-
1557
- if (typeof channelIDOrCustom === 'string') {
1558
- channelID = channelIDOrCustom;
1559
- } else if (typeof channelIDOrCustom === 'object' && channelIDOrCustom !== null) {
1560
- customData = channelIDOrCustom as ChannelData<ErmisChatGenerics>;
1561
- }
1562
-
1563
- return this.getChannelById(channelType, channelID, customData);
1564
- }
1565
-
1566
- getChannelById = (channelType: string, channelID: string | undefined, custom: ChannelData<ErmisChatGenerics>) => {
1567
- const cid = `${channelType}:${channelID || ''}`;
1568
- if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1569
- const channel = this.activeChannels[cid];
1570
- if (Object.keys(custom).length > 0) {
1571
- channel.data = custom;
1572
- channel._data = custom;
1573
- }
1574
- return channel;
1575
- }
1576
- const channel = new Channel<ErmisChatGenerics>(this, channelType, channelID, custom);
1577
- this.activeChannels[channel.cid] = channel;
1578
-
1579
- return channel;
1580
- };
1581
-
1582
- getChannel = (channelType: string, custom: ChannelData<ErmisChatGenerics>) => {
1583
- const uuid = randomId();
1584
- const id = `${this.projectId}:${uuid}`;
1585
- // only allow 1 channel object per cid
1586
- const cid = `${channelType}:${id}`;
1587
- if (cid in this.activeChannels && !this.activeChannels[cid].disconnected) {
1588
- const channel = this.activeChannels[cid];
1589
- if (Object.keys(custom).length > 0) {
1590
- channel.data = custom;
1591
- channel._data = custom;
1592
- }
1593
- return channel;
1594
- }
1595
- const channel = new Channel<ErmisChatGenerics>(this, channelType, id, custom);
1596
- this.activeChannels[channel.cid] = channel;
1597
-
1598
- return channel;
1599
- };
1600
-
1601
- /**
1602
- * Creates a quick channel and immediately registers it on the server.
1603
- * Quick channels are public group channels that anyone can join without an invitation.
1604
- * The creator is added as the first member automatically.
1605
- *
1606
- * @param name - An optional display name for the channel.
1607
- * @returns A promise that resolves to the created `Channel` object.
1608
- */
1609
- async createQuickChannel(name?: string): Promise<Channel<ErmisChatGenerics>> {
1610
- if (!this.userID) {
1611
- throw Error('Call connectUser before creating a channel');
1612
- }
1613
-
1614
- const now = new Date();
1615
- const formattedDate = new Intl.DateTimeFormat('en-US', {
1616
- month: 'short',
1617
- day: '2-digit',
1618
- year: 'numeric',
1619
- hour: '2-digit',
1620
- minute: '2-digit',
1621
- hour12: false,
1622
- }).format(now);
1623
-
1624
- const payload = {
1625
- name: name || `Quick Channel - ${formattedDate}`,
1626
- members: [this.userID],
1627
- public: true,
1628
- } as unknown as ChannelData<ErmisChatGenerics>;
1629
-
1630
- const quickChannel = this.channel('meeting', payload);
1631
- await quickChannel.create();
1632
-
1633
- return quickChannel;
1634
- }
1635
-
1636
- /**
1637
- * Joins a quick channel by its ID.
1638
- * Automatically checks whether the caller is already a member.
1639
- * If not, it joins the channel and synchronizes state.
1640
- *
1641
- * @param channelId - The ID of the quick channel to join.
1642
- * @returns A promise that resolves to the joined `Channel` object.
1643
- */
1644
- async joinQuickChannel(channelId: string): Promise<Channel<ErmisChatGenerics>> {
1645
- if (!this.userID) {
1646
- throw Error('Call connectUser before joining a channel');
1647
- }
1648
-
1649
- const quickChannel = this.channel('meeting', channelId);
1650
- await quickChannel.watch();
1651
-
1652
- const isMember = quickChannel.state.members && quickChannel.state.members[this.userID];
1653
-
1654
- if (!isMember) {
1655
- await quickChannel.acceptInvite('join');
1656
- await quickChannel.watch();
1657
- }
1658
-
1659
- return quickChannel;
1660
- }
1661
-
1662
- _normalizeExpiration(timeoutOrExpirationDate?: null | number | string | Date) {
1663
- let pinExpires: null | string = null;
1664
- if (typeof timeoutOrExpirationDate === 'number') {
1665
- const now = new Date();
1666
- now.setSeconds(now.getSeconds() + timeoutOrExpirationDate);
1667
- pinExpires = now.toISOString();
1668
- } else if (isString(timeoutOrExpirationDate)) {
1669
- pinExpires = timeoutOrExpirationDate;
1670
- } else if (timeoutOrExpirationDate instanceof Date) {
1671
- pinExpires = timeoutOrExpirationDate.toISOString();
1672
- }
1673
- return pinExpires;
1674
- }
1675
-
1676
- getUserAgent() {
1677
- return (
1678
- this.userAgent || `ermis-chat-sdk-javascript-client-${this.node ? 'node' : 'browser'}-${process.env.PKG_VERSION}`
1679
- );
1680
- }
1681
-
1682
- setUserAgent(userAgent: string) {
1683
- this.userAgent = userAgent;
1684
- }
1685
-
1686
- _enrichAxiosOptions(
1687
- options: AxiosRequestConfig & { config?: AxiosRequestConfig } = {
1688
- params: {},
1689
- headers: {},
1690
- config: {},
1691
- },
1692
- ): AxiosRequestConfig {
1693
- let token = this._getToken();
1694
-
1695
- if (!token?.startsWith('Bearer ')) {
1696
- token = `Bearer ${token}`;
1697
- }
1698
-
1699
- const authorization = token ? { Authorization: token } : undefined;
1700
-
1701
- if (!options.headers?.['x-client-request-id']) {
1702
- options.headers = {
1703
- ...options.headers,
1704
- 'x-client-request-id': randomId(),
1705
- };
1706
- }
1707
- const {
1708
- params: axiosRequestConfigParams,
1709
- headers: axiosRequestConfigHeaders,
1710
- ...axiosRequestConfigRest
1711
- } = this.options.axiosRequestConfig || {};
1712
-
1713
- let user_service_params = {
1714
- ...options.params,
1715
- ...(axiosRequestConfigParams || {}),
1716
- };
1717
-
1718
- return {
1719
- params: user_service_params,
1720
- headers: {
1721
- ...authorization,
1722
- 'stream-auth-type': this.getAuthType(),
1723
- 'X-Stream-Client': this.getUserAgent(),
1724
- ...options.headers,
1725
- ...(axiosRequestConfigHeaders || {}),
1726
- },
1727
-
1728
- ...options.config,
1729
- ...(axiosRequestConfigRest || {}),
1730
- };
1731
- }
1732
-
1733
- _getToken() {
1734
- if (!this.tokenManager) return null;
1735
-
1736
- return this.tokenManager.getToken();
1737
- }
1738
-
1739
- _startCleaning() {
1740
- const that = this;
1741
- if (this.cleaningIntervalRef != null) {
1742
- return;
1743
- }
1744
- this.cleaningIntervalRef = setInterval(() => {
1745
- // call clean on the channel, used for calling the stop.typing event etc.
1746
- for (const channel of Object.values(that.activeChannels)) {
1747
- channel.clean();
1748
- }
1749
- }, 500);
1750
- }
1751
-
1752
- _buildWSPayload = (client_request_id?: string) => {
1753
- return JSON.stringify({
1754
- user_id: this.userID,
1755
- user_details: this.user,
1756
- client_request_id,
1757
- });
1758
- };
1759
- }