@vibexnpm/talkx 8.4.1 → 8.6.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.
@@ -68,6 +68,10 @@ const PENDING_NAVIGATION_STORE_NAME = 'pendingNavigation';
68
68
  // SW 와 동일한 key 규칙 사용 — 멀티테넌트에서 다른 projectId 의 pending 을 건드리지 않도록 한다.
69
69
  const PENDING_NAVIGATION_KEY_PREFIX = 'pending-room';
70
70
  const PENDING_NAVIGATION_LEGACY_KEY = 'pending-room';
71
+ // Gmail 메일 알림 클릭 — 방과 다른 key 라 consumePendingRoom 과 섞이지 않는다. 프로젝트별로만 저장한다(레거시 전역 key 없음).
72
+ const PENDING_MAIL_KEY_PREFIX = 'pending-mail';
73
+ // 앱이 이 시간 안에 꺼내지 않은 메일 이동은 버린다 — 며칠 뒤 앱을 열 때 엉뚱하게 메일 화면으로 가지 않게.
74
+ const PENDING_MAIL_MAX_AGE_MS = 10 * 60 * 1000;
71
75
 
72
76
  // deviceId 저장 — 같은 IndexedDB 의 별도 store 사용 (XSS 방어 + 기존 인프라 재사용).
73
77
  // DB 버전은 2 로 bump — v1 에서 pendingNavigation 만 있던 상태에서 deviceInfo store 추가.
@@ -175,6 +179,54 @@ function generateDeviceId() {
175
179
  return 'web-' + random;
176
180
  }
177
181
 
182
+ /**
183
+ * SW 가 저장한 메일 이동(알림 클릭 → 열린 창 없음)을 꺼내고 지운다. 같은 프로젝트·10분 안의 것만.
184
+ *
185
+ * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
186
+ */
187
+ async function consumePendingMailFromIndexedDB(projectId) {
188
+ if (!projectId || typeof projectId !== 'string' || projectId.trim() === '') {
189
+ return null;
190
+ }
191
+ const db = await openPendingNavigationDb();
192
+ if (!db) {
193
+ return null;
194
+ }
195
+ const key = PENDING_MAIL_KEY_PREFIX + ':' + projectId;
196
+ return new Promise((resolve, reject) => {
197
+ const transaction = db.transaction(PENDING_NAVIGATION_STORE_NAME, 'readwrite');
198
+ const store = transaction.objectStore(PENDING_NAVIGATION_STORE_NAME);
199
+ let pending = null;
200
+ const request = store.get(key);
201
+ request.onsuccess = () => {
202
+ const record = request.result;
203
+ if (!record) {
204
+ return;
205
+ }
206
+ store.delete(key);
207
+ const fresh = typeof record.savedAt === 'number' && Date.now() - record.savedAt <= PENDING_MAIL_MAX_AGE_MS;
208
+ if (fresh && record.projectId === projectId && record.connectionId) {
209
+ pending = { connectionId: record.connectionId, messageId: record.messageId || null };
210
+ }
211
+ };
212
+ request.onerror = () => {
213
+ reject(request.error || new Error('IndexedDB read failed'));
214
+ };
215
+ transaction.oncomplete = () => {
216
+ db.close();
217
+ resolve(pending);
218
+ };
219
+ transaction.onerror = () => {
220
+ db.close();
221
+ reject(transaction.error || new Error('IndexedDB transaction failed'));
222
+ };
223
+ transaction.onabort = () => {
224
+ db.close();
225
+ reject(transaction.error || new Error('IndexedDB transaction aborted'));
226
+ };
227
+ });
228
+ }
229
+
178
230
  async function consumePendingRoomFromIndexedDB(projectId) {
179
231
  const db = await openPendingNavigationDb();
180
232
  if (!db) {
@@ -302,6 +354,17 @@ class PushManager {
302
354
  return consumePendingRoomFromIndexedDB(projectId);
303
355
  }
304
356
 
357
+ /**
358
+ * 서비스 워커가 cold start 시 저장한 메일 이동(Gmail 메일 알림 클릭)을 꺼내고 지운다.
359
+ * 앱 부팅 때 호출해 메일 화면으로 라우팅한다 — {@code messageId} 가 있으면 그 메일 상세, 없으면(묶음 알림) 받은메일함.
360
+ *
361
+ * @param {string} projectId
362
+ * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
363
+ */
364
+ static async consumePendingMail(projectId) {
365
+ return consumePendingMailFromIndexedDB(projectId);
366
+ }
367
+
305
368
  /**
306
369
  * 현재 브라우저의 푸시 권한 상태를 조회한다.
307
370
  *
@@ -669,6 +732,15 @@ class PushManager {
669
732
  return PushManager.consumePendingRoom(this.projectId);
670
733
  }
671
734
 
735
+ /**
736
+ * 서비스 워커가 cold start 시 저장한 메일 이동을 꺼내고 지운다 — {@link PushManager.consumePendingMail}.
737
+ *
738
+ * @returns {Promise<{connectionId: string, messageId: (string|null)}|null>}
739
+ */
740
+ async consumePendingMail() {
741
+ return PushManager.consumePendingMail(this.projectId);
742
+ }
743
+
672
744
  /**
673
745
  * 현재 푸시 상태를 정리한다.
674
746
  * 로그아웃 또는 다른 사용자로 전환할 때 foreground listener 와 활성화 상태를 초기화한다.
@@ -51,6 +51,9 @@ const CHAT_DELEGATE_METHODS = [
51
51
  'subscribeRoomList',
52
52
  'unsubscribeRoomList',
53
53
  'isRoomListSubscribed',
54
+ 'subscribeMail',
55
+ 'unsubscribeMail',
56
+ 'isMailSubscribed',
54
57
  'getSubscribedRooms',
55
58
  'isSubscribed',
56
59
  'startTyping',
@@ -1,3 +1,5 @@
1
+ import { emitNewMailFromSocket } from './mailNotifications.js';
2
+
1
3
  const CONNECTION_EVENT_MAP = [
2
4
  ['connected', 'connected'],
3
5
  ['disconnected', 'disconnected'],
@@ -49,6 +51,10 @@ const CHAT_EVENT_MAP = [
49
51
  ['roomListDeleted', 'roomListDeleted'],
50
52
  ['roomListRoomUpdated', 'roomListRoomUpdated'],
51
53
  ['roomListUnreadUpdated', 'roomListUnreadUpdated'],
54
+ // Gmail 메일함 — mailChanged 는 받은 그대로. 새 메일 알림(newMail)은 아래에서 푸시와 합쳐 따로 낸다.
55
+ ['mailSubscribed', 'mailSubscribed'],
56
+ ['mailUnsubscribed', 'mailUnsubscribed'],
57
+ ['mailChanged', 'mailChanged'],
52
58
  ['retentionCleanup', 'retentionCleanup']
53
59
  ];
54
60
 
@@ -62,6 +68,7 @@ const WEBRTC_EVENT_MAP = [
62
68
  ['mediaStateChanged', 'mediaStateChanged'],
63
69
  ['callStarted', 'callStarted'],
64
70
  ['callEnded', 'callEnded'],
71
+ ['callRecoveryFailed', 'callRecoveryFailed'],
65
72
  ['callRequested', 'callRequested'],
66
73
  ['callAccepted', 'callAccepted'],
67
74
  ['callRejected', 'callRejected'],
@@ -102,6 +109,7 @@ export function setupEventForwarding(client) {
102
109
 
103
110
  if (client.chat) {
104
111
  forwardMappedEvents(client.chat, client, CHAT_EVENT_MAP);
112
+ client.chat.on('mailChanged', (event) => emitNewMailFromSocket(client, event));
105
113
  }
106
114
 
107
115
  if (client.webrtc) {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Gmail 새 메일 알림 — 웹소켓 {@code MAIL_CHANGED} 의 미리보기와 포그라운드 FCM 메일 푸시를 합쳐 메일마다 한 번만
3
+ * {@code newMail} 을 낸다 (서버 GMAIL_MAILBOX_FEATURES_DESIGN.md §6-7).
4
+ *
5
+ * <p>같은 메일이 두 길로 온다: 서버는 앱 접속 여부와 상관없이 FCM 을 보내고(채팅과 같음), 화면이 떠 있으면 웹소켓
6
+ * 이벤트도 받는다. 중복 판정 키는 {@code projectId:connectionId:messageId} — 어느 쪽이 먼저 와도 두 번째는 버린다.
7
+ * 묶음 푸시({@code kind=SUMMARY})도 묶은 메시지 ID({@code data.messageIds})로 같은 판정을 하고, 처음 보는 메일이 있을 때만
8
+ * 그 수로 {@code newMailSummary} 를 낸다(같은 묶음이 다시 와도, 개별 알림을 이미 받았어도 다시 띄우지 않는다).</p>
9
+ *
10
+ * <p>보낸 사람·제목은 로그에 남기지 않는다.</p>
11
+ */
12
+
13
+ /** 같은 메일을 다시 보지 않는 시간 — 서버 푸시 수명(수신 후 30분)보다 길게. */
14
+ const DEDUP_TTL_MS = 60 * 60 * 1000;
15
+ /** 기억하는 메일 수 상한 — 넘으면 오래된 것부터 잊는다. */
16
+ const DEDUP_MAX = 1000;
17
+
18
+ export function createMailNotificationDeduper({ ttlMs = DEDUP_TTL_MS, max = DEDUP_MAX, now = () => Date.now() } = {}) {
19
+ const seen = new Map();
20
+ return {
21
+ /** 처음 보는 키면 true(그리고 기억), 이미 본 키면 false. */
22
+ accept(key) {
23
+ const at = now();
24
+ for (const [seenKey, seenAt] of seen) {
25
+ if (at - seenAt < ttlMs) {
26
+ break; // Map 은 넣은 순서 — 첫 살아 있는 항목부터는 다 살아 있다
27
+ }
28
+ seen.delete(seenKey);
29
+ }
30
+ if (seen.has(key)) {
31
+ return false;
32
+ }
33
+ seen.set(key, at);
34
+ while (seen.size > max) {
35
+ seen.delete(seen.keys().next().value);
36
+ }
37
+ return true;
38
+ },
39
+ size() {
40
+ return seen.size;
41
+ }
42
+ };
43
+ }
44
+
45
+ function deduperOf(client) {
46
+ if (!client._mailNotificationDeduper) {
47
+ client._mailNotificationDeduper = createMailNotificationDeduper();
48
+ }
49
+ return client._mailNotificationDeduper;
50
+ }
51
+
52
+ function projectIdOf(client) {
53
+ return (client.options && client.options.projectId) || null;
54
+ }
55
+
56
+ function keyOf(projectId, connectionId, messageId) {
57
+ return `${projectId || ''}:${connectionId}:${messageId}`;
58
+ }
59
+
60
+ function senderOf(from) {
61
+ if (!from || typeof from !== 'object') {
62
+ return null;
63
+ }
64
+ return (typeof from.name === 'string' && from.name.trim() !== '') ? from.name : (from.email || null);
65
+ }
66
+
67
+ /**
68
+ * 웹소켓 {@code MAIL_CHANGED} → 새 메일마다 {@code newMail}. 읽음·보관 같은 변화(미리보기 없음)는 아무것도 내지 않는다.
69
+ */
70
+ export function emitNewMailFromSocket(client, event) {
71
+ if (!event || event.eventType !== 'MAIL_CHANGED' || !Array.isArray(event.messages) || !event.connectionId) {
72
+ return;
73
+ }
74
+ const projectId = projectIdOf(client);
75
+ const deduper = deduperOf(client);
76
+ for (const preview of event.messages) {
77
+ if (!preview || !preview.messageId) {
78
+ continue;
79
+ }
80
+ if (!deduper.accept(keyOf(projectId, event.connectionId, preview.messageId))) {
81
+ continue;
82
+ }
83
+ client.emit('newMail', {
84
+ projectId,
85
+ connectionId: event.connectionId,
86
+ messageId: preview.messageId,
87
+ threadId: preview.threadId || null,
88
+ sender: senderOf(preview.from),
89
+ from: preview.from || null,
90
+ subject: preview.subject || null,
91
+ receivedAt: preview.receivedAt || null,
92
+ source: 'socket'
93
+ });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * 포그라운드 FCM 메일 푸시({@code data.target === 'MAIL'}) → {@code newMail}(한 통) 또는 {@code newMailSummary}(묶음).
99
+ * 메일 푸시면 {@code true} — 호출자는 {@code pushNotification} 을 내지 않는다(같은 알림을 두 번 띄우지 않게).
100
+ */
101
+ export function handleMailPush(client, payload) {
102
+ const data = (payload && payload.data) || {};
103
+ if (data.target !== 'MAIL') {
104
+ return false;
105
+ }
106
+ const projectId = projectIdOf(client);
107
+ if (!data.connectionId) {
108
+ return true;
109
+ }
110
+ if (data.kind === 'SUMMARY') {
111
+ const ids = typeof data.messageIds === 'string'
112
+ ? data.messageIds.split(',').map((id) => id.trim()).filter(Boolean)
113
+ : [];
114
+ if (ids.length === 0) {
115
+ // 메시지 ID 없는 묶음(구버전 서버) — 거를 기준이 없어 건수 그대로 낸다.
116
+ const count = Number.parseInt(data.count, 10);
117
+ client.emit('newMailSummary', {
118
+ projectId,
119
+ connectionId: data.connectionId,
120
+ count: Number.isFinite(count) ? count : null,
121
+ messageIds: [],
122
+ source: 'push'
123
+ });
124
+ return true;
125
+ }
126
+ const deduper = deduperOf(client);
127
+ const unseen = ids.filter((id) => deduper.accept(keyOf(projectId, data.connectionId, id)));
128
+ if (unseen.length > 0) {
129
+ client.emit('newMailSummary', {
130
+ projectId,
131
+ connectionId: data.connectionId,
132
+ count: unseen.length,
133
+ messageIds: unseen,
134
+ source: 'push'
135
+ });
136
+ }
137
+ return true;
138
+ }
139
+ if (!data.messageId || !deduperOf(client).accept(keyOf(projectId, data.connectionId, data.messageId))) {
140
+ return true;
141
+ }
142
+ client.emit('newMail', {
143
+ projectId,
144
+ connectionId: data.connectionId,
145
+ messageId: data.messageId,
146
+ threadId: data.threadId || null,
147
+ sender: (payload.notification && payload.notification.title) || data.title || null,
148
+ from: null,
149
+ subject: (payload.notification && payload.notification.body) || data.body || null,
150
+ receivedAt: null,
151
+ source: 'push'
152
+ });
153
+ return true;
154
+ }
@@ -3,6 +3,7 @@ import ConnectionManager from '../core/ConnectionManager.js';
3
3
  import ChatClient from '../chat/ChatClient.js';
4
4
  import WebRTCClient from '../webrtc/WebRTCClient.js';
5
5
  import PushManager from '../push/PushManager.js';
6
+ import { handleMailPush } from './mailNotifications.js';
6
7
 
7
8
  export function initializeSubClients(client) {
8
9
  if (client.connectionManager) {
@@ -22,6 +23,7 @@ export function initializeSubClients(client) {
22
23
  reconnectDelay: client.options.reconnectDelay,
23
24
  maxReconnectDelay: client.options.maxReconnectDelay,
24
25
  maxReconnectAttempts: client.options.maxReconnectAttempts,
26
+ reconnectRecoveryDelay: client.options.reconnectRecoveryDelay,
25
27
  heartbeatIncoming: client.options.heartbeatIncoming,
26
28
  heartbeatOutgoing: client.options.heartbeatOutgoing,
27
29
  resumeOnVisibility: client.options.resumeOnVisibility,
@@ -31,6 +33,8 @@ export function initializeSubClients(client) {
31
33
  if (!client.authSession) return client.options.jwtToken;
32
34
  return client.authSession.getAccessToken({ forceRefresh, rejectedToken: client.connectionManager?.jwtToken });
33
35
  };
36
+ // 레거시 모드의 공급자는 정적 토큰을 돌려줄 뿐이다. 새 토큰은 updateToken() 으로만 온다.
37
+ client.connectionManager.canRefreshToken = () => !!client.authSession;
34
38
 
35
39
  client._chat = new ChatClient({
36
40
  connectionManager: client.connectionManager,
@@ -127,6 +131,7 @@ export function applySessionMethods(TalkFlowClient) {
127
131
  if (this.chat) {
128
132
  this.chat.unsubscribeAllRooms();
129
133
  this.chat.unsubscribeRoomList();
134
+ this.chat.unsubscribeMail();
130
135
  }
131
136
 
132
137
  await this.connectionManager.disconnect();
@@ -217,6 +222,11 @@ export function applySessionMethods(TalkFlowClient) {
217
222
  return;
218
223
  }
219
224
 
225
+ // Gmail 메일 푸시 — 웹소켓 미리보기와 합쳐 newMail/newMailSummary 로만 낸다(pushNotification 아님).
226
+ if (handleMailPush(this, payload)) {
227
+ return;
228
+ }
229
+
220
230
  const activeRoomId = this.chat?.getActiveRoom();
221
231
 
222
232
  if (activeRoomId && activeRoomId === data.roomId) {
@@ -291,6 +301,10 @@ export function applySessionMethods(TalkFlowClient) {
291
301
  return PushManager.consumePendingRoom(this.options.projectId);
292
302
  };
293
303
 
304
+ TalkFlowClient.prototype.consumePendingMail = async function consumePendingMail() {
305
+ return PushManager.consumePendingMail(this.options.projectId);
306
+ };
307
+
294
308
  TalkFlowClient.prototype.setCurrentDeviceEnabled = function setCurrentDeviceEnabled(enabled) {
295
309
  return this.pushManager?.setCurrentDeviceEnabled(enabled);
296
310
  };
@@ -91,6 +91,15 @@ class PeerConnectionManager extends EventEmitter {
91
91
  return connection;
92
92
  }
93
93
 
94
+ /** 그룹 시작 중 answer를 먼저 보낸 peer에 로컬 트랙을 보낼 협상을 재개한다. */
95
+ async resumeAutoNegotiation(peerId) {
96
+ const peer = this.peers.get(peerId);
97
+ if (!peer || peer.connection.signalingState !== 'stable') return false;
98
+ peer.skipAutoNegotiation = false;
99
+ await peer.connection.onnegotiationneeded();
100
+ return true;
101
+ }
102
+
94
103
  /**
95
104
  * 연결 이벤트 핸들러 설정
96
105
  * @private
@@ -146,7 +155,7 @@ class PeerConnectionManager extends EventEmitter {
146
155
  // 협상 필요 (Perfect Negotiation)
147
156
  connection.onnegotiationneeded = async () => {
148
157
  // 1:1 통화에서 수동으로 offer를 생성할 때는 자동 negotiation 건너뛰기
149
- if (peerState.skipAutoNegotiation) {
158
+ if (peerState.skipAutoNegotiation || peerState.makingOffer || connection.signalingState !== 'stable') {
150
159
  this.logger.debug(`Skipping auto negotiation for ${peerId} (manual offer will be sent)`);
151
160
  return;
152
161
  }