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