@vibexnpm/talkx 8.5.0 → 8.7.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/dist/index.d.ts +62 -13
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/TalkFlowClient.js +21 -9
- package/src/auth/AuthSession.js +27 -2
- package/src/chat/ChatClient.js +54 -19
- package/src/constants.js +1 -0
- package/src/core/ConnectionManager.js +166 -63
- package/src/index.js +1 -0
- package/src/talkflow/eventForwarding.js +1 -0
- package/src/talkflow/session.js +3 -0
- package/src/webrtc/PeerConnectionManager.js +10 -1
- package/src/webrtc/WebRTCClient.js +287 -28
- package/types/__typetests__/authSession.types.ts +23 -1
- package/types/index.d.ts +62 -13
package/package.json
CHANGED
package/src/TalkFlowClient.js
CHANGED
|
@@ -39,6 +39,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
39
39
|
* // 1단계: 로그인 — 클라이언트 생성 «전» 에 부른다. ck/projectId/JWT 를 한 번에 받는다.
|
|
40
40
|
* // const { clientKey, projectId, accessToken } = await TalkFlowClient.loginUser({
|
|
41
41
|
* // serverUrl, email, password });
|
|
42
|
+
* // (회사 계정이 둘 이상이면 먼저 workspaceSelectionRequired 결과 → projectId 를 실어 다시 부른다)
|
|
42
43
|
*
|
|
43
44
|
* // 2단계: 받은 값으로 SDK 초기화
|
|
44
45
|
* const client = new TalkFlowClient({
|
|
@@ -72,6 +73,7 @@ class TalkFlowClient extends EventEmitter {
|
|
|
72
73
|
reconnectDelay: options.reconnectDelay || DefaultConfig.reconnectDelay,
|
|
73
74
|
maxReconnectDelay: options.maxReconnectDelay || DefaultConfig.maxReconnectDelay,
|
|
74
75
|
maxReconnectAttempts: options.maxReconnectAttempts || DefaultConfig.maxReconnectAttempts,
|
|
76
|
+
reconnectRecoveryDelay: options.reconnectRecoveryDelay ?? DefaultConfig.reconnectRecoveryDelay,
|
|
75
77
|
heartbeatIncoming: options.heartbeatIncoming || DefaultConfig.heartbeatIncoming,
|
|
76
78
|
heartbeatOutgoing: options.heartbeatOutgoing || DefaultConfig.heartbeatOutgoing,
|
|
77
79
|
// 탭 복귀 / 네트워크 복구 시 자동 재연결. false 로 꺼야 하므로 !== false 로 보존 —
|
|
@@ -309,21 +311,28 @@ class TalkFlowClient extends EventEmitter {
|
|
|
309
311
|
* <p>Server Key 도 고객사 BFF 도 필요 없다. 응답의
|
|
310
312
|
* {@code clientKey}/{@code projectId}/{@code accessToken} 이 그대로 SDK 초기화 입력이다.</p>
|
|
311
313
|
*
|
|
312
|
-
* <p><b>회사
|
|
313
|
-
*
|
|
314
|
+
* <p><b>회사 선택</b>: 같은 이메일이 여러 회사에 계정을 가질 수 있다(다른 회사의 게스트 등). 비밀번호가 맞는 계정이
|
|
315
|
+
* 둘 이상이면 토큰 없이 {@code {workspaceSelectionRequired: true, workspaces: [{projectId, companyName, companyRole}]}}
|
|
316
|
+
* 를 돌려준다 — 저장된 세션은 그대로다. 고른 {@code projectId} 를 실어 같은 이메일·비밀번호로 다시 부른다.
|
|
317
|
+
* 계정이 하나면 지금처럼 바로 로그인된다.</p>
|
|
314
318
|
*
|
|
315
319
|
* @param {Object} options
|
|
316
320
|
* @param {string} options.serverUrl - 채팅 서버 base URL
|
|
317
321
|
* @param {string} options.email
|
|
318
322
|
* @param {string} options.password
|
|
319
|
-
* @
|
|
323
|
+
* @param {string} [options.projectId] - 회사 선택 응답에서 고른 회사
|
|
324
|
+
* @returns {Promise<Object>} `{accessToken, tokenType, expiresIn, projectId, clientKey, user}` 또는 회사 선택 결과
|
|
320
325
|
*
|
|
321
326
|
* @example
|
|
322
|
-
*
|
|
327
|
+
* let login = await TalkFlowClient.loginUser({
|
|
323
328
|
* serverUrl: 'https://chat.apiorbit.net',
|
|
324
329
|
* email: 'dan@example.com',
|
|
325
330
|
* password: '********'
|
|
326
331
|
* });
|
|
332
|
+
* if (login.workspaceSelectionRequired) {
|
|
333
|
+
* const picked = await showWorkspacePicker(login.workspaces); // 화면에서 고른 회사
|
|
334
|
+
* login = await TalkFlowClient.loginUser({ serverUrl, email, password, projectId: picked.projectId });
|
|
335
|
+
* }
|
|
327
336
|
*
|
|
328
337
|
* const client = new TalkFlowClient({
|
|
329
338
|
* apiKey: login.clientKey,
|
|
@@ -332,13 +341,13 @@ class TalkFlowClient extends EventEmitter {
|
|
|
332
341
|
* });
|
|
333
342
|
* await client.connect();
|
|
334
343
|
*/
|
|
335
|
-
static async loginUser({ serverUrl, email, password }) {
|
|
344
|
+
static async loginUser({ serverUrl, email, password, projectId }) {
|
|
336
345
|
if (!serverUrl) throw new Error('serverUrl is required');
|
|
337
346
|
if (!email) throw new Error('email is required');
|
|
338
347
|
if (!password) throw new Error('password is required');
|
|
339
348
|
|
|
340
349
|
// 로그인은 키를 «받기 위해» 부르는 경로라 X-API-KEY/X-PROJECT-ID 가 없다(서버에서 필터 제외).
|
|
341
|
-
return AuthSession.forServer(serverUrl).login({ email, password });
|
|
350
|
+
return AuthSession.forServer(serverUrl).login({ email, password, projectId });
|
|
342
351
|
}
|
|
343
352
|
|
|
344
353
|
/**
|
|
@@ -347,16 +356,19 @@ class TalkFlowClient extends EventEmitter {
|
|
|
347
356
|
* <p>이미 클라이언트가 있는 상태의 재로그인·계정 전환용이다. 최초 진입은 클라이언트 생성 전에
|
|
348
357
|
* 부르는 정적 {@link TalkFlowClient.loginUser} 를 쓴다 — 그쪽이 {@code clientKey} 까지 준다.</p>
|
|
349
358
|
*
|
|
359
|
+
* <p>회사 선택 응답이면 토큰을 설정하지 않고 그 결과를 돌려준다 — 정적 {@link TalkFlowClient.loginUser} 와 같다.</p>
|
|
360
|
+
*
|
|
350
361
|
* @param {Object} credentials
|
|
351
362
|
* @param {string} credentials.email
|
|
352
363
|
* @param {string} credentials.password
|
|
353
|
-
* @
|
|
364
|
+
* @param {string} [credentials.projectId] - 회사 선택 응답에서 고른 회사
|
|
365
|
+
* @returns {Promise<Object>} 로그인 응답 또는 회사 선택 결과
|
|
354
366
|
*/
|
|
355
|
-
async loginUser({ email, password }) {
|
|
367
|
+
async loginUser({ email, password, projectId }) {
|
|
356
368
|
if (!email || !password) throw new Error('email and password are required');
|
|
357
369
|
this._enableRefreshSession();
|
|
358
370
|
await this._authReady;
|
|
359
|
-
return this.authSession.login({ email, password });
|
|
371
|
+
return this.authSession.login({ email, password, projectId });
|
|
360
372
|
}
|
|
361
373
|
|
|
362
374
|
/**
|
package/src/auth/AuthSession.js
CHANGED
|
@@ -4,6 +4,21 @@ const sessions = new Map();
|
|
|
4
4
|
const TERMINAL_CODES = new Set(['REFRESH_INVALID', 'REFRESH_REUSED', 'SESSION_REVOKED', 'ACCOUNT_BLOCKED', 'LOGIN_FAILED']);
|
|
5
5
|
export const isTerminalAuthError = error => TERMINAL_CODES.has(error?.response?.code || error?.code);
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* 로그인 회사 선택 결과 — 토큰이 없다. 목록이 비었거나 회사 id 가 없는 응답은 계약 위반이라 인증 불가로 끊는다(빈 선택 화면을 띄우지 않게).
|
|
9
|
+
*/
|
|
10
|
+
function workspaceSelection(data) {
|
|
11
|
+
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
|
|
12
|
+
if (workspaces.length === 0 || workspaces.some(workspace => !workspace?.projectId)) {
|
|
13
|
+
const error = new Error('Invalid authentication response'); error.code = 'AUTH_UNAVAILABLE'; throw error;
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
workspaceSelectionRequired: true,
|
|
17
|
+
workspaces: workspaces.map(({ projectId, companyName, companyRole }) =>
|
|
18
|
+
({ projectId, companyName: companyName ?? null, companyRole }))
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
export function tokenClaims(token) {
|
|
8
23
|
try {
|
|
9
24
|
const value = token.replace(/^Bearer\s+/i, '').split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -240,13 +255,23 @@ export default class AuthSession extends EventEmitter {
|
|
|
240
255
|
} finally { clearTimeout(timeout); }
|
|
241
256
|
}
|
|
242
257
|
|
|
243
|
-
|
|
258
|
+
/**
|
|
259
|
+
* 같은 이메일로 여러 회사 계정이 있고 비밀번호가 둘 이상 맞으면 서버는 토큰 없이 회사 목록만 준다. 그 응답은
|
|
260
|
+
* {@link workspaceSelection} 결과로 돌려준다. 저장소·인증 상태·이전 세션은 건드리지 않는다 — 서버도 이 단계에서는
|
|
261
|
+
* 이전 세션을 끊지 않는다. 호출자는 고른 {@code projectId} 를 실어 같은 자격으로 다시 부른다(서버가 비밀번호를 다시 검증).
|
|
262
|
+
*/
|
|
263
|
+
async login({ email, password, projectId }) {
|
|
244
264
|
const generation = ++this.generation;
|
|
245
265
|
return this._locked(async () => {
|
|
246
266
|
const previous = this._readMarker();
|
|
247
267
|
this._writeMarker(previous);
|
|
248
268
|
if (previous.pendingRefreshToken) await this._request('/v1/auth/signout', { refreshToken: previous.pendingRefreshToken, deviceId: previous.deviceId });
|
|
249
|
-
const data = await this._request('/api/v1/auth/login',
|
|
269
|
+
const data = await this._request('/api/v1/auth/login',
|
|
270
|
+
{ email, password, previousRefreshToken: previous.refreshToken, ...(projectId ? { projectId } : {}) });
|
|
271
|
+
if (data?.workspaceSelectionRequired === true) {
|
|
272
|
+
if (generation !== this.generation) throw this._cancelled();
|
|
273
|
+
return workspaceSelection(data);
|
|
274
|
+
}
|
|
250
275
|
if (!data?.refreshToken) {
|
|
251
276
|
const error = new Error('Server does not support the refresh session contract');
|
|
252
277
|
error.code = 'AUTH_PROTOCOL_UNSUPPORTED';
|
package/src/chat/ChatClient.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import EventEmitter from '../utils/EventEmitter.js';
|
|
9
9
|
import Logger from '../utils/Logger.js';
|
|
10
|
-
import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig } from '../constants.js';
|
|
10
|
+
import { WebSocketPaths, LogLevel, RoomListEventType, ChatRoomType, DefaultConfig, ConnectionState } from '../constants.js';
|
|
11
11
|
|
|
12
12
|
const MAX_FILES_PER_MESSAGE = 20;
|
|
13
13
|
|
|
@@ -186,6 +186,8 @@ class ChatClient extends EventEmitter {
|
|
|
186
186
|
// 상수로 두면 maxSyncPages 튜닝 시 조용히 어긋나므로 config 에서 파생시킨다 (설계 §7).
|
|
187
187
|
this._maxSeenPerRoom = Math.max(200, this.maxSyncPages * this.syncPageSize + 100);
|
|
188
188
|
|
|
189
|
+
this._reconnectSyncGeneration = 0;
|
|
190
|
+
this._delayedReconnectSyncTimer = null;
|
|
189
191
|
this._installSyncTriggers();
|
|
190
192
|
}
|
|
191
193
|
|
|
@@ -205,8 +207,20 @@ class ChatClient extends EventEmitter {
|
|
|
205
207
|
// syncOnReconnect 옵션 + anchor 있는 방이라는 두 겹 필터가 걸려 있어서, 거기 얹으면 옵션이
|
|
206
208
|
// 꺼져 있거나 그 방에서 메시지를 받은 적이 없을 때 replay 가 조용히 빠진다.
|
|
207
209
|
this._onReconnectedForSync = ({ attempts } = {}) => {
|
|
210
|
+
this._cancelDelayedReconnectSync();
|
|
211
|
+
const generation = this._reconnectSyncGeneration;
|
|
208
212
|
this.syncAfterReconnect(`reconnected:${attempts ?? 0}`)
|
|
209
|
-
.catch((error) => this.logger.warn('Gap sync after reconnect failed:', error))
|
|
213
|
+
.catch((error) => this.logger.warn('Gap sync after reconnect failed:', error))
|
|
214
|
+
.then((cursors) => {
|
|
215
|
+
if (!this.syncOnReconnect || !cursors || generation !== this._reconnectSyncGeneration) return;
|
|
216
|
+
// SUBSCRIBE 전송과 SimpleBroker 등록은 다른 시점이다. 첫 snapshot 뒤의 공백을 다시 읽는다.
|
|
217
|
+
this._delayedReconnectSyncTimer = setTimeout(() => {
|
|
218
|
+
this._delayedReconnectSyncTimer = null;
|
|
219
|
+
this._supplementAfterReconnect(cursors, generation)
|
|
220
|
+
.catch(error => this.logger.warn('Delayed gap sync failed:', error));
|
|
221
|
+
}, 1000);
|
|
222
|
+
this._delayedReconnectSyncTimer.unref?.();
|
|
223
|
+
});
|
|
210
224
|
this.resumeAssistantStreams(`reconnected:${attempts ?? 0}`)
|
|
211
225
|
.catch((error) => this.logger.warn('Assistant stream replay after reconnect failed:', error));
|
|
212
226
|
};
|
|
@@ -217,6 +231,10 @@ class ChatClient extends EventEmitter {
|
|
|
217
231
|
.catch((error) => this.logger.warn('Assistant stream replay after resume failed:', error));
|
|
218
232
|
};
|
|
219
233
|
|
|
234
|
+
this._onStateChangeForSync = ({ state }) => {
|
|
235
|
+
if (state !== ConnectionState.CONNECTED) this._cancelDelayedReconnectSync();
|
|
236
|
+
};
|
|
237
|
+
this.connectionManager.on('stateChange', this._onStateChangeForSync);
|
|
220
238
|
this.connectionManager.on('reconnected', this._onReconnectedForSync);
|
|
221
239
|
this.connectionManager.on('resumed', this._onResumedForSync);
|
|
222
240
|
}
|
|
@@ -1596,8 +1614,7 @@ class ChatClient extends EventEmitter {
|
|
|
1596
1614
|
* @private
|
|
1597
1615
|
* @returns {Promise<Object|null>} 복구 결과. 대상이 아니면 null.
|
|
1598
1616
|
*/
|
|
1599
|
-
async _syncRoom(roomId) {
|
|
1600
|
-
const anchor = this._anchor.get(roomId);
|
|
1617
|
+
async _syncRoom(roomId, anchor = this._anchor.get(roomId), quiet = false) {
|
|
1601
1618
|
// 이 세션에서 아직 받은 메시지가 없는 방 — enterRoom 의 초기 로드가 담당한다.
|
|
1602
1619
|
if (!anchor) return null;
|
|
1603
1620
|
if (this._syncInFlight.has(roomId)) return null;
|
|
@@ -1631,25 +1648,25 @@ class ChatClient extends EventEmitter {
|
|
|
1631
1648
|
if (hasMore) {
|
|
1632
1649
|
// 상한 초과 — 버퍼를 통째로 버리고 최신 페이지로 전환한다(부분 replay 금지).
|
|
1633
1650
|
this.logger.info(`Gap too large, falling back to latest page: room=${roomId}`);
|
|
1634
|
-
this.emit('gapTruncated', { roomId, fetched: buffer.length });
|
|
1651
|
+
if (!quiet) this.emit('gapTruncated', { roomId, fetched: buffer.length });
|
|
1635
1652
|
const reloaded = await this._reloadLatestPage(roomId, genAtStart);
|
|
1636
1653
|
return {
|
|
1637
|
-
roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
|
|
1654
|
+
roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
|
|
1638
1655
|
};
|
|
1639
1656
|
}
|
|
1640
1657
|
|
|
1641
1658
|
const recovered = this._replayMessages(roomId, buffer);
|
|
1642
1659
|
this._commitAnchorFromSync(roomId, cursor, genAtStart);
|
|
1643
|
-
return { roomId, recovered, truncated: false, durationMs: Date.now() - startedAt };
|
|
1660
|
+
return { roomId, cursor, recovered, truncated: false, durationMs: Date.now() - startedAt };
|
|
1644
1661
|
|
|
1645
1662
|
} catch (error) {
|
|
1646
1663
|
if (this._isCursorExpired(error)) {
|
|
1647
1664
|
// retention 정리 등으로 앵커가 사라짐 — 최신 페이지로 복구한다.
|
|
1648
1665
|
this.logger.warn(`Sync cursor expired, reloading latest: room=${roomId}`);
|
|
1649
|
-
this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
|
|
1666
|
+
if (!quiet) this.emit('gapTruncated', { roomId, fetched: 0, reason: 'cursorExpired' });
|
|
1650
1667
|
const reloaded = await this._reloadLatestPage(roomId, genAtStart);
|
|
1651
1668
|
return {
|
|
1652
|
-
roomId, recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
|
|
1669
|
+
roomId, cursor: this._anchor.get(roomId), recovered: reloaded, truncated: true, durationMs: Date.now() - startedAt
|
|
1653
1670
|
};
|
|
1654
1671
|
}
|
|
1655
1672
|
throw error;
|
|
@@ -1665,7 +1682,7 @@ class ChatClient extends EventEmitter {
|
|
|
1665
1682
|
* 상호 배타적이라 중복 실행되지 않는다.</p>
|
|
1666
1683
|
*
|
|
1667
1684
|
* @param {string} [reason]
|
|
1668
|
-
* @returns {Promise<
|
|
1685
|
+
* @returns {Promise<Map<string, string>|undefined>} 1차 조회 cursor — 내부 보충 조회용.
|
|
1669
1686
|
*/
|
|
1670
1687
|
async syncAfterReconnect(reason = 'manual') {
|
|
1671
1688
|
if (!this.syncOnReconnect) return;
|
|
@@ -1679,11 +1696,15 @@ class ChatClient extends EventEmitter {
|
|
|
1679
1696
|
this.emit('syncStarted', { roomIds, reason });
|
|
1680
1697
|
const startedAt = Date.now();
|
|
1681
1698
|
const rooms = [];
|
|
1699
|
+
const cursors = new Map();
|
|
1682
1700
|
|
|
1683
1701
|
for (const roomId of roomIds) {
|
|
1684
1702
|
try {
|
|
1685
1703
|
const result = await this._syncRoom(roomId);
|
|
1686
|
-
if (result)
|
|
1704
|
+
if (result) {
|
|
1705
|
+
rooms.push(result);
|
|
1706
|
+
cursors.set(roomId, result.cursor);
|
|
1707
|
+
}
|
|
1687
1708
|
} catch (error) {
|
|
1688
1709
|
// 한 방의 실패가 나머지 방 복구를 막지 않게 한다.
|
|
1689
1710
|
this.logger.warn(`Gap sync failed: room=${roomId}`, error);
|
|
@@ -1698,6 +1719,17 @@ class ChatClient extends EventEmitter {
|
|
|
1698
1719
|
durationMs: Date.now() - startedAt,
|
|
1699
1720
|
reason
|
|
1700
1721
|
});
|
|
1722
|
+
return cursors;
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
/** 첫 조회가 끝난 cursor부터 조용히 보충한다. 목록·동기화 이벤트는 첫 조회의 책임이다. */
|
|
1726
|
+
async _supplementAfterReconnect(cursors, generation) {
|
|
1727
|
+
for (const [roomId, cursor] of cursors) {
|
|
1728
|
+
if (generation !== this._reconnectSyncGeneration) return;
|
|
1729
|
+
if (!this.subscribedRooms.has(roomId)) continue;
|
|
1730
|
+
try { await this._syncRoom(roomId, cursor, true); }
|
|
1731
|
+
catch (error) { this.logger.warn(`Supplemental gap sync failed: room=${roomId}`, error); }
|
|
1732
|
+
}
|
|
1701
1733
|
}
|
|
1702
1734
|
|
|
1703
1735
|
/**
|
|
@@ -2479,8 +2511,8 @@ class ChatClient extends EventEmitter {
|
|
|
2479
2511
|
* @param {string} roomId - 채팅방 ID
|
|
2480
2512
|
* @returns {Promise<void>}
|
|
2481
2513
|
*
|
|
2482
|
-
* @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
|
|
2483
|
-
* @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl }], participantCount, timestamp }
|
|
2514
|
+
* @fires memberJoined - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
|
|
2515
|
+
* @fires memberLeft - { roomId, members: [{ userId, nickname, profileUrl, companyRole? }], participantCount, timestamp }
|
|
2484
2516
|
*/
|
|
2485
2517
|
async subscribeRoom(roomId) {
|
|
2486
2518
|
if (this.subscribedRooms.has(roomId)) {
|
|
@@ -3864,13 +3896,16 @@ class ChatClient extends EventEmitter {
|
|
|
3864
3896
|
this.logger.setLevel(level);
|
|
3865
3897
|
}
|
|
3866
3898
|
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3899
|
+
_cancelDelayedReconnectSync() {
|
|
3900
|
+
this._reconnectSyncGeneration++;
|
|
3901
|
+
clearTimeout(this._delayedReconnectSyncTimer);
|
|
3902
|
+
this._delayedReconnectSyncTimer = null;
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
/** 구독, 예약된 복구, 방별 진행 표시를 정리한다. */
|
|
3873
3906
|
destroy() {
|
|
3907
|
+
this._cancelDelayedReconnectSync();
|
|
3908
|
+
this.connectionManager.off?.('stateChange', this._onStateChangeForSync);
|
|
3874
3909
|
this.unsubscribeAllRooms();
|
|
3875
3910
|
if (this.roomListSubscribed) {
|
|
3876
3911
|
this.unsubscribeRoomList();
|
package/src/constants.js
CHANGED