@ethora/chat-component 26.10.6 → 26.10.8
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 +102 -3
- package/dist/ChatProfileModal-83t5BRkI.js +2298 -0
- package/dist/{FilePreviewModal-DwNUEHei.js → FilePreviewModal-DxUGjSHe.js} +135 -135
- package/dist/{ManageDataModal-CaQ2VBPn.js → ManageDataModal-DFE_Wegj.js} +24 -24
- package/dist/{MarkdownBody-BeoG2cpo.js → MarkdownBody-uFITrPjT.js} +1 -1
- package/dist/{MessageSearchModal-DMw9o49-.js → MessageSearchModal-CsbTWgNm.js} +46 -46
- package/dist/{PublicChatsModal-CWdYILv_.js → PublicChatsModal-bDOp3IL9.js} +18 -18
- package/dist/{SideDrawer-Cl-vbuIG.js → SideDrawer-A_1ECqD2.js} +29 -29
- package/dist/UserProfileModal-Ca5wHYvr.js +639 -0
- package/dist/{UserSettingsModal-DgCAHmNh.js → UserSettingsModal-CAyyQGV6.js} +4 -4
- package/dist/{VideoCallOverlay-Cq2cECWj.js → VideoCallOverlay-BMiQh8kI.js} +6605 -4726
- package/dist/{VisibilityModal-D7vqGbrA.js → VisibilityModal-BXyIy7_J.js} +12 -12
- package/dist/assets/icons.d.ts +4 -0
- package/dist/calls/callDisplay.d.ts +22 -0
- package/dist/calls/callMode.d.ts +105 -0
- package/dist/calls/openScheduledCall.d.ts +20 -0
- package/dist/calls/p2p/P2PCallController.d.ts +160 -0
- package/dist/calls/p2p/icePolicy.d.ts +36 -0
- package/dist/calls/p2p/negotiator.d.ts +56 -0
- package/dist/calls/p2p/p2pRuntime.d.ts +35 -0
- package/dist/calls/p2p/signalInbox.d.ts +34 -0
- package/dist/calls/p2p/signalStanza.d.ts +51 -0
- package/dist/calls/placeCall.d.ts +20 -0
- package/dist/calls/scheduledCall.d.ts +101 -0
- package/dist/calls/scheduledCallActions.d.ts +12 -0
- package/dist/calls/scheduledCallFormat.d.ts +13 -0
- package/dist/calls/scheduledCallsConfig.d.ts +27 -0
- package/dist/calls/senderTrust.d.ts +7 -0
- package/dist/calls/startCall.d.ts +16 -0
- package/dist/components/MainComponents/ChatWrapper.d.ts +1 -0
- package/dist/components/MainComponents/LoginWrapper.d.ts +1 -0
- package/dist/components/MainComponents/ProfileImagePlaceholder.d.ts +1 -0
- package/dist/components/MainComponents/ReduxWrapper.d.ts +6 -0
- package/dist/components/MenuRoom/MenuRoom.d.ts +1 -0
- package/dist/components/Modals/NewChatModal/styledNewChatComponents.d.ts +27 -0
- package/dist/components/Modals/styledModalComponents.d.ts +25 -4
- package/dist/components/ScheduledCalls/ScheduleCallModal.d.ts +3 -0
- package/dist/components/ScheduledCalls/ScheduledCallCancelConfirm.d.ts +8 -0
- package/dist/components/ScheduledCalls/ScheduledCallCard.d.ts +7 -0
- package/dist/components/ScheduledCalls/ScheduledCallLayer.d.ts +15 -0
- package/dist/components/ScheduledCalls/ScheduledCallPreJoin.d.ts +3 -0
- package/dist/components/VideoCalls/P2PCallRunner.d.ts +1 -0
- package/dist/components/VideoCalls/P2PCallSession.d.ts +14 -0
- package/dist/components/VideoCalls/VideoCallSession.d.ts +39 -0
- package/dist/firebase-messaging-sw.js +918 -96
- package/dist/{handleCopyClick-Crn2jEEu.js → handleCopyClick-BX1fcFGN.js} +1 -1
- package/dist/helpers/callClaim.d.ts +17 -0
- package/dist/helpers/callOutcomeSync.d.ts +23 -0
- package/dist/helpers/callPush.d.ts +25 -0
- package/dist/helpers/canLeaveRoom.d.ts +3 -0
- package/dist/helpers/chatComponentConfigEvents.d.ts +16 -0
- package/dist/helpers/incomingCallPush.d.ts +89 -0
- package/dist/helpers/liveConfig.d.ts +46 -0
- package/dist/helpers/pushRoomHandoff.d.ts +13 -0
- package/dist/helpers/reportRoom.d.ts +18 -0
- package/dist/helpers/scheduledCallMessage.d.ts +23 -0
- package/dist/helpers/storeConsole.d.ts +1 -0
- package/dist/hooks/useChatSettingState.d.ts +1 -0
- package/dist/hooks/useNow.d.ts +6 -0
- package/dist/hooks/usePushNotifications.d.ts +2 -0
- package/dist/hooks/useRemoteAppearance.d.ts +21 -0
- package/dist/hooks/useTypography.d.ts +4 -0
- package/dist/{main-36gqdbJ8.js → main-B25iFKAC.js} +16623 -13399
- package/dist/main.d.ts +3 -1
- package/dist/main.js +25 -23
- package/dist/networking/api-requests/callState.api.d.ts +24 -0
- package/dist/networking/api-requests/remoteAppearance.api.d.ts +20 -0
- package/dist/networking/api-requests/rooms.api.d.ts +11 -1
- package/dist/networking/api-requests/scheduledCalls.api.d.ts +19 -0
- package/dist/networking/ethoraEvents.d.ts +14 -1
- package/dist/networking/xmpp/notifyXmppEvent.d.ts +6 -0
- package/dist/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
- package/dist/networking/xmppClient.d.ts +2 -1
- package/dist/roomStore/Middleware/blockedHydrationMiddleware.d.ts +2 -0
- package/dist/roomStore/callSlice.d.ts +23 -1
- package/dist/roomStore/chatSettingsSlice.d.ts +11 -2
- package/dist/roomStore/index.d.ts +4 -0
- package/dist/roomStore/scheduledCallSlice.d.ts +47 -0
- package/dist/types/models/config.model.d.ts +78 -1
- package/dist/types/models/message.model.d.ts +2 -0
- package/dist/types/models/room.model.d.ts +2 -0
- package/dist/types/models/scheduledCall.model.d.ts +116 -0
- package/dist/types/models/xmpp.model.d.ts +2 -1
- package/dist/utils/callAuthMirror.d.ts +1 -0
- package/dist/utils/callBroadcast.d.ts +12 -0
- package/dist/utils/callNotifications.d.ts +67 -0
- package/dist/utils/firebasePushNotifications.d.ts +14 -0
- package/dist/utils/handledCalls.d.ts +5 -0
- package/dist/utils/pushServiceWorkerBridge.d.ts +19 -0
- package/dist/utils/webLock.d.ts +13 -0
- package/package.json +1 -1
- package/public/firebase-messaging-sw.js +918 -96
- package/dist/ChatProfileModal-OJCKwdNp.js +0 -2168
- package/dist/UserProfileModal-B8KtGnwZ.js +0 -594
|
@@ -9,6 +9,7 @@ const DEDUP_TTL = 60 * 1000;
|
|
|
9
9
|
const params = new URL(self.location.href).searchParams;
|
|
10
10
|
const CUSTOM_ICON = (params.get('iconPath') || '').trim();
|
|
11
11
|
const CUSTOM_BADGE = (params.get('badgePath') || '').trim();
|
|
12
|
+
const CUSTOM_CLICK_PATH = (params.get('clickPath') || '').trim();
|
|
12
13
|
const DEFAULT_ICON = `${self.location.origin}/favicon-192.png`;
|
|
13
14
|
const DEFAULT_BADGE = `${self.location.origin}/favicon-192.png`;
|
|
14
15
|
|
|
@@ -34,6 +35,23 @@ function error(...args) {
|
|
|
34
35
|
|
|
35
36
|
const APP_URL = self.location.origin;
|
|
36
37
|
|
|
38
|
+
// Where a tap that has to open a window lands. The host can name the page
|
|
39
|
+
// that mounts the chat (iconPath-style, via the registration url); only a
|
|
40
|
+
// same-origin path is accepted, anything else falls back to the origin root.
|
|
41
|
+
function sanitizeClickPath(value) {
|
|
42
|
+
const path = typeof value === 'string' ? value.trim() : '';
|
|
43
|
+
if (!path || path.charAt(0) !== '/') return '';
|
|
44
|
+
try {
|
|
45
|
+
const resolved = new URL(path, self.location.origin);
|
|
46
|
+
if (resolved.origin !== self.location.origin) return '';
|
|
47
|
+
return `${resolved.pathname}${resolved.search}`;
|
|
48
|
+
} catch {
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const CLICK_URL = `${APP_URL}${sanitizeClickPath(CUSTOM_CLICK_PATH) || '/'}`;
|
|
54
|
+
|
|
37
55
|
// ── DEDUP ─────────────────────────────────────────────────────
|
|
38
56
|
const handledPushes = new Map();
|
|
39
57
|
|
|
@@ -176,17 +194,28 @@ function pickBody(raw = {}, data = {}, notification = {}) {
|
|
|
176
194
|
return notification.body || raw.body || data.body || 'You have a new message';
|
|
177
195
|
}
|
|
178
196
|
|
|
197
|
+
// The url the push itself names (backend messages and reactions name none).
|
|
198
|
+
// Only string values count: a payload field that is not a string (or a
|
|
199
|
+
// String.prototype method read off a non-object payload) is not a url.
|
|
200
|
+
function pickExplicitUrl(raw = {}, data = {}, notification = {}) {
|
|
201
|
+
const candidates = [
|
|
202
|
+
data.url,
|
|
203
|
+
data.link,
|
|
204
|
+
data.click_action,
|
|
205
|
+
notification.click_action,
|
|
206
|
+
raw.url,
|
|
207
|
+
raw.link,
|
|
208
|
+
raw.click_action,
|
|
209
|
+
];
|
|
210
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
211
|
+
const value = candidates[i];
|
|
212
|
+
if (typeof value === 'string' && value.trim()) return value.trim();
|
|
213
|
+
}
|
|
214
|
+
return '';
|
|
215
|
+
}
|
|
216
|
+
|
|
179
217
|
function pickUrl(raw = {}, data = {}, notification = {}) {
|
|
180
|
-
return normalizeUrl(
|
|
181
|
-
data.url ||
|
|
182
|
-
data.link ||
|
|
183
|
-
data.click_action ||
|
|
184
|
-
notification.click_action ||
|
|
185
|
-
raw.url ||
|
|
186
|
-
raw.link ||
|
|
187
|
-
raw.click_action ||
|
|
188
|
-
APP_URL
|
|
189
|
-
);
|
|
218
|
+
return normalizeUrl(pickExplicitUrl(raw, data, notification) || CLICK_URL);
|
|
190
219
|
}
|
|
191
220
|
|
|
192
221
|
// An icon the push itself carries (data.icon, notification.icon or icon on
|
|
@@ -197,7 +226,17 @@ function pickUrl(raw = {}, data = {}, notification = {}) {
|
|
|
197
226
|
function sanitizeIconUrl(value) {
|
|
198
227
|
const url = typeof value === 'string' ? value.trim() : '';
|
|
199
228
|
if (!url) return '';
|
|
200
|
-
if (url.charAt(0) === '/'
|
|
229
|
+
if (url.charAt(0) === '/') {
|
|
230
|
+
// A path only counts when it really stays on this origin: `//host/x` and
|
|
231
|
+
// `/\host/x` (a backslash is a slash to the url parser) both leave it.
|
|
232
|
+
try {
|
|
233
|
+
return new URL(url, self.location.origin).origin === self.location.origin
|
|
234
|
+
? url
|
|
235
|
+
: '';
|
|
236
|
+
} catch {
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
}
|
|
201
240
|
return /^https?:\/\/[^\s]+$/i.test(url) ? url : '';
|
|
202
241
|
}
|
|
203
242
|
|
|
@@ -215,14 +254,24 @@ function pickBadge() {
|
|
|
215
254
|
return CUSTOM_BADGE || DEFAULT_BADGE;
|
|
216
255
|
}
|
|
217
256
|
|
|
257
|
+
// A photo/video message carries a thumbnail as data.previewUrl. An image the
|
|
258
|
+
// push names explicitly (data.image, notification.image, image) still wins.
|
|
259
|
+
// Every candidate goes through sanitizeIconUrl (http(s) or a same-origin
|
|
260
|
+
// path), so a data:/javascript: value never reaches showNotification.
|
|
218
261
|
function pickImage(raw = {}, data = {}, notification = {}) {
|
|
219
|
-
return
|
|
262
|
+
return (
|
|
263
|
+
sanitizeIconUrl(data.image) ||
|
|
264
|
+
sanitizeIconUrl(notification.image) ||
|
|
265
|
+
sanitizeIconUrl(raw.image) ||
|
|
266
|
+
sanitizeIconUrl(data.previewUrl) ||
|
|
267
|
+
undefined
|
|
268
|
+
);
|
|
220
269
|
}
|
|
221
270
|
|
|
222
271
|
// ── CALL DETECTION ────────────────────────────────────────────
|
|
223
272
|
// Mirror of helpers/callPush.ts isCallPush(). A backgrounded call push must
|
|
224
273
|
// (a) read as a call (persistent, call-styled title) and (b) carry its FULL
|
|
225
|
-
// data payload through to the click handler
|
|
274
|
+
// data payload through to the click handler, otherwise the app's isCallPush()
|
|
226
275
|
// sees only {url,messageId,...} and can't raise the ring screen.
|
|
227
276
|
function pickField(data, keys) {
|
|
228
277
|
for (let i = 0; i < keys.length; i += 1) {
|
|
@@ -261,10 +310,80 @@ function isCallPushData(data) {
|
|
|
261
310
|
return !!callId && hasCallField;
|
|
262
311
|
}
|
|
263
312
|
|
|
313
|
+
function isReactionPushData(data = {}) {
|
|
314
|
+
return String(data.type || '').toLowerCase() === 'reaction';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The server defaults a call push without `kind` to audio.
|
|
264
318
|
function callPushKind(data) {
|
|
265
|
-
return pickField(data, ['callKind', 'kind']).toLowerCase() === '
|
|
266
|
-
? '
|
|
267
|
-
: '
|
|
319
|
+
return pickField(data, ['callKind', 'kind']).toLowerCase() === 'video'
|
|
320
|
+
? 'video'
|
|
321
|
+
: 'audio';
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── CALL FRESHNESS ────────────────────────────────────────────
|
|
325
|
+
// The backend sends call pushes without a TTL, so a device that was offline can
|
|
326
|
+
// receive one long after the call. The LiveKit token in the push lives 60 s, so
|
|
327
|
+
// its `exp` is the freshness clock (mirror of helpers/callPush.ts).
|
|
328
|
+
const CALL_MIN_REMAINING_MS = 3000;
|
|
329
|
+
const CALL_ACK_TIMEOUT_MS = 1500;
|
|
330
|
+
const CALL_KEEPALIVE_CAP_MS = 60 * 1000;
|
|
331
|
+
const CALL_ACTIONS = [
|
|
332
|
+
{ action: 'answer', title: 'Answer' },
|
|
333
|
+
{ action: 'decline', title: 'Decline' },
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
function callTokenOf(data) {
|
|
337
|
+
return pickField(data, ['callToken', 'livekitToken', 'token']);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function callIdOf(data) {
|
|
341
|
+
return pickField(data, ['callId', 'callid', 'callID']);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function callRoomOf(data) {
|
|
345
|
+
return pickField(data, ['callRoom', 'room', 'roomJid', 'jid', 'chatId']);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function jwtExpiresAtMs(token) {
|
|
349
|
+
try {
|
|
350
|
+
const parts = String(token || '').split('.');
|
|
351
|
+
if (parts.length < 2) return null;
|
|
352
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
353
|
+
const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4);
|
|
354
|
+
const exp = Number(JSON.parse(atob(padded)).exp);
|
|
355
|
+
return Number.isFinite(exp) && exp > 0 ? exp * 1000 : null;
|
|
356
|
+
} catch {
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// No readable exp (no token, malformed token): treated as fresh, because it
|
|
362
|
+
// cannot be proven stale. The ring is still bounded: it never outlives
|
|
363
|
+
// receivedAt + CALL_KEEPALIVE_CAP_MS (the backend's own TTL for a call).
|
|
364
|
+
function callPushFreshness(data, now) {
|
|
365
|
+
const expiresAt = jwtExpiresAtMs(callTokenOf(data));
|
|
366
|
+
if (expiresAt === null) return { fresh: true, expiresAt: null };
|
|
367
|
+
return { fresh: expiresAt - now >= CALL_MIN_REMAINING_MS, expiresAt };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// One key for a call on both sides (the worker and the page, see
|
|
371
|
+
// utils/callNotifications.ts): the callId, or the room when the push has none.
|
|
372
|
+
// The OS notification tag is `call:<key>` and the missed-call marker uses the
|
|
373
|
+
// same key, so the two can never leave two notifications for one call.
|
|
374
|
+
function callRoomBare(data) {
|
|
375
|
+
return callRoomOf(data).split('@')[0].trim();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function callKeyOf(data) {
|
|
379
|
+
const callId = callIdOf(data);
|
|
380
|
+
if (callId) return callId;
|
|
381
|
+
const room = callRoomBare(data);
|
|
382
|
+
return room ? `room:${room}` : '';
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function sleep(ms) {
|
|
386
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
268
387
|
}
|
|
269
388
|
|
|
270
389
|
function extractMessageId(raw = {}, data = {}) {
|
|
@@ -320,11 +439,21 @@ function normalizePush(event) {
|
|
|
320
439
|
|
|
321
440
|
const json = safeJsonParse(text);
|
|
322
441
|
|
|
323
|
-
if (json) {
|
|
442
|
+
if (json && typeof json === 'object' && !Array.isArray(json)) {
|
|
324
443
|
log('PUSH JSON', json);
|
|
325
444
|
return { type: 'json', raw: json, text };
|
|
326
445
|
}
|
|
327
446
|
|
|
447
|
+
// A JSON string (`"hello"`) is a plain text push; a number, boolean or
|
|
448
|
+
// array carries no message either way and is shown as the text it was sent as.
|
|
449
|
+
if (typeof json === 'string') {
|
|
450
|
+
return {
|
|
451
|
+
type: 'text',
|
|
452
|
+
raw: { title: 'New Message', body: json, data: {} },
|
|
453
|
+
text,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
328
457
|
warn('NOT JSON PUSH', text);
|
|
329
458
|
|
|
330
459
|
return {
|
|
@@ -360,9 +489,17 @@ async function broadcastDebugMessage(payload) {
|
|
|
360
489
|
}
|
|
361
490
|
|
|
362
491
|
// ── MAIN HANDLER ──────────────────────────────────────────────
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
492
|
+
function isPlainObject(value) {
|
|
493
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async function handlePush(rawInput = {}, source = 'push') {
|
|
497
|
+
// Whatever arrived, work on plain objects only: a string payload would
|
|
498
|
+
// otherwise expose String.prototype members (raw.link) as fields, and a
|
|
499
|
+
// string `data` would be spread character by character.
|
|
500
|
+
const raw = isPlainObject(rawInput) ? rawInput : {};
|
|
501
|
+
const data = isPlainObject(raw.data) ? raw.data : {};
|
|
502
|
+
const notification = isPlainObject(raw.notification) ? raw.notification : {};
|
|
366
503
|
|
|
367
504
|
const title = pickTitle(raw, data, notification);
|
|
368
505
|
const body = pickBody(raw, data, notification);
|
|
@@ -372,7 +509,12 @@ async function handlePush(raw = {}, source = 'push') {
|
|
|
372
509
|
const image = pickImage(raw, data, notification);
|
|
373
510
|
|
|
374
511
|
const messageId = extractMessageId(raw, data);
|
|
375
|
-
const
|
|
512
|
+
const isReaction = isReactionPushData(data);
|
|
513
|
+
const dedupKey = isReaction
|
|
514
|
+
? `reaction:${simpleHash(
|
|
515
|
+
stableStringify({ title, body, jid: data.jid, msgID: data.msgID })
|
|
516
|
+
)}`
|
|
517
|
+
: makeDedupKey(raw, data, notification);
|
|
376
518
|
const iconAvailability = DEBUG
|
|
377
519
|
? await checkAssetAvailability(icon)
|
|
378
520
|
: undefined;
|
|
@@ -449,9 +591,10 @@ async function handlePush(raw = {}, source = 'push') {
|
|
|
449
591
|
'title',
|
|
450
592
|
])
|
|
451
593
|
: '';
|
|
452
|
-
const callId = isCall
|
|
453
|
-
|
|
454
|
-
|
|
594
|
+
const callId = isCall ? callIdOf(data) : '';
|
|
595
|
+
const callKey = isCall ? callKeyOf(data) || dedupKey : '';
|
|
596
|
+
// A call without a LiveKit token cannot be answered from the notification.
|
|
597
|
+
const canAnswer = isCall && !!callTokenOf(data);
|
|
455
598
|
|
|
456
599
|
// Call pushes: a call-styled title, a tag keyed on the callId so repeat
|
|
457
600
|
// call-token pushes replace (not stack), and requireInteraction so the
|
|
@@ -459,51 +602,284 @@ async function handlePush(raw = {}, source = 'push') {
|
|
|
459
602
|
const notifTitle = isCall
|
|
460
603
|
? `Incoming ${callPushKind(data)} call${callerName ? ` · ${callerName}` : ''}`
|
|
461
604
|
: title;
|
|
462
|
-
const notifBody = isCall
|
|
605
|
+
const notifBody = isCall
|
|
606
|
+
? canAnswer
|
|
607
|
+
? 'Tap to answer'
|
|
608
|
+
: 'Tap to open the chat'
|
|
609
|
+
: body;
|
|
463
610
|
|
|
464
611
|
const options = {
|
|
465
612
|
body: notifBody,
|
|
466
613
|
icon,
|
|
467
614
|
badge,
|
|
468
615
|
image,
|
|
469
|
-
|
|
470
|
-
|
|
616
|
+
// A reaction replaces the chat's previous reaction, never its message
|
|
617
|
+
// notification; it comes in silent, the way it does on mobile.
|
|
618
|
+
tag: isCall
|
|
619
|
+
? `call:${callKey}`
|
|
620
|
+
: isReaction && roomTag(data)
|
|
621
|
+
? `${roomTag(data)}#reaction`
|
|
622
|
+
: roomTag(data) || dedupKey,
|
|
623
|
+
silent: isReaction,
|
|
624
|
+
// A reaction is already silent, so it does not set renotify on top.
|
|
625
|
+
renotify: !isReaction && (isCall || !!roomTag(data)),
|
|
471
626
|
requireInteraction: isCall,
|
|
472
627
|
// Spread the ORIGINAL payload first so the click handler's isCallPush()
|
|
473
628
|
// sees the full call fields (callId/callToken/kind/room); our routing
|
|
474
629
|
// fields win on top.
|
|
475
630
|
data: {
|
|
476
631
|
...data,
|
|
477
|
-
url,
|
|
632
|
+
// A call tap or Answer goes to the configured click url, never to a url
|
|
633
|
+
// the payload names: the pending click carries the call, not the url.
|
|
634
|
+
url: isCall ? CLICK_URL : url,
|
|
635
|
+
// true only when the push carried its own url; the click handler never
|
|
636
|
+
// navigates an open tab to the default (app root) url.
|
|
637
|
+
urlExplicit: isCall ? false : !!pickExplicitUrl(raw, data, notification),
|
|
478
638
|
messageId,
|
|
479
639
|
dedupKey,
|
|
480
640
|
source,
|
|
481
641
|
},
|
|
482
642
|
};
|
|
483
643
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
644
|
+
if (isCall) {
|
|
645
|
+
return handleCallPush({
|
|
646
|
+
data,
|
|
647
|
+
options: {
|
|
648
|
+
...options,
|
|
649
|
+
actions: canAnswer ? CALL_ACTIONS : CALL_ACTIONS.filter((a) => a.action === 'decline'),
|
|
650
|
+
},
|
|
651
|
+
title: notifTitle,
|
|
652
|
+
callId,
|
|
653
|
+
callerName,
|
|
654
|
+
windowClients,
|
|
655
|
+
icon,
|
|
656
|
+
badge,
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (!visible) {
|
|
496
661
|
log('SHOW NOTIFICATION', {
|
|
497
|
-
reason:
|
|
662
|
+
reason: 'NO_VISIBLE_CLIENT',
|
|
498
663
|
tag: options.tag,
|
|
499
|
-
isCall,
|
|
500
664
|
});
|
|
501
665
|
|
|
502
666
|
return self.registration.showNotification(notifTitle, options);
|
|
503
667
|
}
|
|
504
668
|
|
|
505
|
-
log('SKIP NOTIFICATION', {
|
|
506
|
-
|
|
669
|
+
log('SKIP NOTIFICATION', { reason: 'VISIBLE_CLIENT' });
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ── CALL PUSH ─────────────────────────────────────────────────
|
|
673
|
+
// A call push is never decided by "does a window exist": a hidden tab with a
|
|
674
|
+
// dead socket never rings itself. Instead every window is asked (CALL_PUSH)
|
|
675
|
+
// and one that is VISIBLE and now shows the ring answers CALL_PUSH_ACK. With no
|
|
676
|
+
// ack inside CALL_ACK_TIMEOUT_MS the worker shows the OS notification itself.
|
|
677
|
+
// An ack can also say the page is busy with another call (`busy`): the worker
|
|
678
|
+
// then shows a plain "Missed call" instead of a ring nobody could answer.
|
|
679
|
+
//
|
|
680
|
+
// The same wait serves the Decline hand-off (CALL_PUSH_DECLINE_ACK): the page
|
|
681
|
+
// acks only after it really handled the decline.
|
|
682
|
+
const ackWaiters = new Map();
|
|
683
|
+
|
|
684
|
+
// Resolves { result: 'ack', source } as soon as an ack arrives (`source` is the
|
|
685
|
+
// window that sent it), { result: 'busy' } when the time is up and only busy
|
|
686
|
+
// acks came in (another tab may still ring, so a busy ack does not cut the wait
|
|
687
|
+
// short), { result: 'none' } otherwise.
|
|
688
|
+
function waitForAck(kind, callId, ms) {
|
|
689
|
+
return new Promise((resolve) => {
|
|
690
|
+
const key = `${kind}:${callId || ''}`;
|
|
691
|
+
let timer = null;
|
|
692
|
+
let busySeen = false;
|
|
693
|
+
const waiter = {
|
|
694
|
+
onAck(busy, source) {
|
|
695
|
+
if (busy) {
|
|
696
|
+
busySeen = true;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
finish('ack', source);
|
|
700
|
+
},
|
|
701
|
+
};
|
|
702
|
+
const finish = (result, source) => {
|
|
703
|
+
const set = ackWaiters.get(key);
|
|
704
|
+
if (set) {
|
|
705
|
+
set.delete(waiter);
|
|
706
|
+
if (!set.size) ackWaiters.delete(key);
|
|
707
|
+
}
|
|
708
|
+
if (timer !== null) clearTimeout(timer);
|
|
709
|
+
resolve({ result, source });
|
|
710
|
+
};
|
|
711
|
+
if (!ackWaiters.has(key)) ackWaiters.set(key, new Set());
|
|
712
|
+
ackWaiters.get(key).add(waiter);
|
|
713
|
+
timer = setTimeout(() => finish(busySeen ? 'busy' : 'none'), ms);
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function resolveAck(kind, callId, busy, source) {
|
|
718
|
+
const set = ackWaiters.get(`${kind}:${callId || ''}`);
|
|
719
|
+
if (!set) return;
|
|
720
|
+
Array.from(set).forEach((waiter) => waiter.onAck(busy === true, source));
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Runs `task` while holding a Web Lock shared with the pages of this origin, so
|
|
724
|
+
// the page and the worker never show the same "Missed call" at the same moment
|
|
725
|
+
// (two showNotification calls with one tag replace each other and alert twice).
|
|
726
|
+
// Without Web Locks the task just runs.
|
|
727
|
+
async function withLock(name, task) {
|
|
728
|
+
const locks = typeof navigator !== 'undefined' ? navigator.locks : null;
|
|
729
|
+
if (!locks || typeof locks.request !== 'function') return task();
|
|
730
|
+
let ran = false;
|
|
731
|
+
try {
|
|
732
|
+
return await locks.request(name, async () => {
|
|
733
|
+
ran = true;
|
|
734
|
+
return task();
|
|
735
|
+
});
|
|
736
|
+
} catch (e) {
|
|
737
|
+
if (ran) throw e;
|
|
738
|
+
return task();
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// A call that was missed (stale push, nobody answered before the call ended, or
|
|
743
|
+
// the page was busy with another call) is a normal notification: not sticky,
|
|
744
|
+
// one per room, tap opens the room. It deliberately carries no callId/kind, so
|
|
745
|
+
// the page's isCallPush() does not take it for a call. The page shows the same
|
|
746
|
+
// notification itself when it times a ring out (same tag, same data), so
|
|
747
|
+
// whichever gets there first wins; `missedCallId` tells a repeat of the same
|
|
748
|
+
// call from the next missed call in the room.
|
|
749
|
+
async function showMissedCall(data, icon, badge) {
|
|
750
|
+
const callerName = pickField(data, [
|
|
751
|
+
'callerName',
|
|
752
|
+
'callerDisplayName',
|
|
753
|
+
'senderName',
|
|
754
|
+
'title',
|
|
755
|
+
]);
|
|
756
|
+
const room = callRoomOf(data);
|
|
757
|
+
const bare = callRoomBare(data);
|
|
758
|
+
const tag = `missed-call:${bare || callIdOf(data)}`;
|
|
759
|
+
const missedCallId = callKeyOf(data);
|
|
760
|
+
// The check and the show are one step under a lock the page takes too (see
|
|
761
|
+
// showMissedCallNotification): when both see the ring end in the same few
|
|
762
|
+
// milliseconds, the second finds the first one's notification and leaves it.
|
|
763
|
+
return withLock(`ethora-missed-call:${tag}`, async () => {
|
|
764
|
+
if (missedCallId) {
|
|
765
|
+
try {
|
|
766
|
+
const existing = await self.registration.getNotifications({ tag });
|
|
767
|
+
if (
|
|
768
|
+
existing &&
|
|
769
|
+
existing.some((n) => n.data && n.data.missedCallId === missedCallId)
|
|
770
|
+
) {
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
} catch (e) {
|
|
774
|
+
warn('getNotifications failed', e);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return self.registration.showNotification(
|
|
778
|
+
`Missed ${callPushKind(data)} call${callerName ? ` · ${callerName}` : ''}`,
|
|
779
|
+
{
|
|
780
|
+
body: 'Tap to open the chat',
|
|
781
|
+
icon,
|
|
782
|
+
badge,
|
|
783
|
+
tag,
|
|
784
|
+
renotify: true,
|
|
785
|
+
requireInteraction: false,
|
|
786
|
+
data: {
|
|
787
|
+
type: 'missed-call',
|
|
788
|
+
jid: room,
|
|
789
|
+
callerName,
|
|
790
|
+
missedCallId,
|
|
791
|
+
url: CLICK_URL,
|
|
792
|
+
urlExplicit: false,
|
|
793
|
+
source: 'missed-call',
|
|
794
|
+
},
|
|
795
|
+
}
|
|
796
|
+
);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Keeps the event alive until the ring ends, then swaps a ring that is still on
|
|
801
|
+
// screen for the "Missed call" notification. The ring ends at the token's exp,
|
|
802
|
+
// but never later than receivedAt + 60 s (the backend's TTL for a call): a push
|
|
803
|
+
// with no readable exp, or an exp further away, must not stay sticky forever
|
|
804
|
+
// when no page is open to end it. Runs for the rings this worker shows itself
|
|
805
|
+
// (push) and for the ones a page shows through the registration
|
|
806
|
+
// (CALL_RING_SHOWN): a page-shown ring is persistent too, and the tab that
|
|
807
|
+
// showed it can be closed before the ring is over. One watcher per tag.
|
|
808
|
+
const expiryWatchers = new Set();
|
|
809
|
+
|
|
810
|
+
async function expireCallNotification({ tag, data, endAt, icon, badge }) {
|
|
811
|
+
if (expiryWatchers.has(tag)) return;
|
|
812
|
+
expiryWatchers.add(tag);
|
|
813
|
+
try {
|
|
814
|
+
// A timer can fire a millisecond before the clock reads endAt; waking up
|
|
815
|
+
// early and giving up would leave the ring on screen for good.
|
|
816
|
+
for (let wait = endAt - Date.now(); wait > 0; wait = endAt - Date.now()) {
|
|
817
|
+
await sleep(wait);
|
|
818
|
+
}
|
|
819
|
+
let open = [];
|
|
820
|
+
try {
|
|
821
|
+
open = await self.registration.getNotifications({ tag });
|
|
822
|
+
} catch (e) {
|
|
823
|
+
warn('getNotifications failed', e);
|
|
824
|
+
}
|
|
825
|
+
if (!open || !open.length) return;
|
|
826
|
+
open.forEach((n) => {
|
|
827
|
+
try {
|
|
828
|
+
n.close();
|
|
829
|
+
} catch {}
|
|
830
|
+
});
|
|
831
|
+
await showMissedCall(data, icon, badge);
|
|
832
|
+
} finally {
|
|
833
|
+
expiryWatchers.delete(tag);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
async function handleCallPush({
|
|
838
|
+
data,
|
|
839
|
+
options,
|
|
840
|
+
title,
|
|
841
|
+
callId,
|
|
842
|
+
windowClients,
|
|
843
|
+
icon,
|
|
844
|
+
badge,
|
|
845
|
+
}) {
|
|
846
|
+
const receivedAt = Date.now();
|
|
847
|
+
const { fresh, expiresAt } = callPushFreshness(data, receivedAt);
|
|
848
|
+
|
|
849
|
+
if (!fresh) {
|
|
850
|
+
log('STALE CALL PUSH', { callId, expiresAt });
|
|
851
|
+
return showMissedCall(data, icon, badge);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (windowClients.length) {
|
|
855
|
+
const acked = waitForAck('call', callId, CALL_ACK_TIMEOUT_MS);
|
|
856
|
+
windowClients.forEach((c) => {
|
|
857
|
+
try {
|
|
858
|
+
c.postMessage({ type: 'CALL_PUSH', data });
|
|
859
|
+
} catch {}
|
|
860
|
+
});
|
|
861
|
+
const result = await acked;
|
|
862
|
+
if (result.result === 'ack') {
|
|
863
|
+
log('CALL RINGS IN PAGE', { callId });
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (result.result === 'busy') {
|
|
867
|
+
log('CALL PAGE BUSY', { callId });
|
|
868
|
+
return showMissedCall(data, icon, badge);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
log('SHOW CALL NOTIFICATION', { tag: options.tag });
|
|
873
|
+
await self.registration.showNotification(title, options);
|
|
874
|
+
await expireCallNotification({
|
|
875
|
+
tag: options.tag,
|
|
876
|
+
data,
|
|
877
|
+
endAt: Math.min(
|
|
878
|
+
expiresAt === null ? Infinity : expiresAt,
|
|
879
|
+
receivedAt + CALL_KEEPALIVE_CAP_MS
|
|
880
|
+
),
|
|
881
|
+
icon,
|
|
882
|
+
badge,
|
|
507
883
|
});
|
|
508
884
|
}
|
|
509
885
|
|
|
@@ -523,7 +899,7 @@ self.addEventListener('push', (event) => {
|
|
|
523
899
|
messageId: extractMessageId(raw, raw.data || {}),
|
|
524
900
|
});
|
|
525
901
|
|
|
526
|
-
return handlePush(raw, `raw_push_${type}`);
|
|
902
|
+
return await handlePush(raw, `raw_push_${type}`);
|
|
527
903
|
} catch (e) {
|
|
528
904
|
error('PUSH HANDLER ERROR', e);
|
|
529
905
|
}
|
|
@@ -531,13 +907,467 @@ self.addEventListener('push', (event) => {
|
|
|
531
907
|
);
|
|
532
908
|
});
|
|
533
909
|
|
|
910
|
+
// ── PENDING CLICK (cold start) ────────────────────────────────
|
|
911
|
+
// A tap with no open window opens one, but the new page has not loaded yet,
|
|
912
|
+
// so a postMessage would be lost. The click payload is kept in Cache Storage
|
|
913
|
+
// (survives the worker being stopped), and the page asks for it with
|
|
914
|
+
// GET_PENDING_PUSH_CLICK once its message listener is attached. It is handed
|
|
915
|
+
// out once and expires after PENDING_CLICK_TTL.
|
|
916
|
+
const PENDING_CLICK_TTL = 2 * 60 * 1000;
|
|
917
|
+
const PENDING_CACHE = 'ethora-push-pending-v1';
|
|
918
|
+
const PENDING_KEY = `${APP_URL}/__ethora_pending_push_click__`;
|
|
919
|
+
let pendingQueue = Promise.resolve();
|
|
920
|
+
|
|
921
|
+
// Serialise cache access so two concurrent requests cannot both read the
|
|
922
|
+
// same pending click before either deletes it.
|
|
923
|
+
function enqueuePending(task) {
|
|
924
|
+
const run = pendingQueue.then(task, task);
|
|
925
|
+
pendingQueue = run.then(
|
|
926
|
+
() => undefined,
|
|
927
|
+
() => undefined
|
|
928
|
+
);
|
|
929
|
+
return run;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function savePendingClick(payload) {
|
|
933
|
+
return enqueuePending(async () => {
|
|
934
|
+
try {
|
|
935
|
+
const cache = await caches.open(PENDING_CACHE);
|
|
936
|
+
await cache.put(
|
|
937
|
+
PENDING_KEY,
|
|
938
|
+
new Response(JSON.stringify({ ts: Date.now(), payload }), {
|
|
939
|
+
headers: { 'Content-Type': 'application/json' },
|
|
940
|
+
})
|
|
941
|
+
);
|
|
942
|
+
} catch (e) {
|
|
943
|
+
warn('savePendingClick failed', e);
|
|
944
|
+
}
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function takePendingClick() {
|
|
949
|
+
return enqueuePending(async () => {
|
|
950
|
+
try {
|
|
951
|
+
const cache = await caches.open(PENDING_CACHE);
|
|
952
|
+
const hit = await cache.match(PENDING_KEY);
|
|
953
|
+
if (!hit) return null;
|
|
954
|
+
await cache.delete(PENDING_KEY);
|
|
955
|
+
const record = await hit.json();
|
|
956
|
+
if (!record || !record.payload) return null;
|
|
957
|
+
if (Date.now() - Number(record.ts || 0) > PENDING_CLICK_TTL) return null;
|
|
958
|
+
return record.payload;
|
|
959
|
+
} catch (e) {
|
|
960
|
+
warn('takePendingClick failed', e);
|
|
961
|
+
return null;
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// Same page, ignoring query and hash: a tab on /chat?chatId=x is already on
|
|
967
|
+
// the route a push to /chat names.
|
|
968
|
+
function samePage(a, b) {
|
|
969
|
+
try {
|
|
970
|
+
const left = new URL(a);
|
|
971
|
+
const right = new URL(b);
|
|
972
|
+
return left.origin === right.origin && left.pathname === right.pathname;
|
|
973
|
+
} catch {
|
|
974
|
+
return a === b;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// A cold-start tap opens a fresh window. Hand the page the room (and message)
|
|
979
|
+
// to open through the url as well: ids only, never tokens or message text.
|
|
980
|
+
// The page treats `fromPush=1` like a tap and then drops the pending click, so
|
|
981
|
+
// the two routes never both apply. Calls keep to the pending click (their
|
|
982
|
+
// token cannot travel in a url), and a url that already names a chat, or that
|
|
983
|
+
// leaves this origin, is left as it is.
|
|
984
|
+
function withPushParams(urlString, data) {
|
|
985
|
+
if (isCallPushData(data)) return urlString;
|
|
986
|
+
const room = String(data.jid || data.roomJid || '').split('@')[0].trim();
|
|
987
|
+
if (!room) return urlString;
|
|
988
|
+
try {
|
|
989
|
+
const next = new URL(urlString);
|
|
990
|
+
if (next.origin !== self.location.origin) return urlString;
|
|
991
|
+
if (next.searchParams.has('chatId')) return urlString;
|
|
992
|
+
next.searchParams.set('fromPush', '1');
|
|
993
|
+
next.searchParams.set('chatId', room);
|
|
994
|
+
const msgId = String(data.msgID || '').trim();
|
|
995
|
+
if (msgId) next.searchParams.set('messageId', msgId);
|
|
996
|
+
return next.toString();
|
|
997
|
+
} catch {
|
|
998
|
+
return urlString;
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function inScope(client) {
|
|
1003
|
+
try {
|
|
1004
|
+
return client.url.startsWith(
|
|
1005
|
+
(self.registration && self.registration.scope) || self.location.origin
|
|
1006
|
+
);
|
|
1007
|
+
} catch {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
function sameOrigin(client) {
|
|
1013
|
+
try {
|
|
1014
|
+
return client.url.startsWith(self.location.origin);
|
|
1015
|
+
} catch {
|
|
1016
|
+
return false;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// Prefer a window inside the worker scope (focused first), then any
|
|
1021
|
+
// same-origin window.
|
|
1022
|
+
function pickClient(allClients) {
|
|
1023
|
+
return (
|
|
1024
|
+
allClients.find((c) => inScope(c) && c.focused) ||
|
|
1025
|
+
allClients.find(inScope) ||
|
|
1026
|
+
allClients.find(sameOrigin) ||
|
|
1027
|
+
allClients[0]
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ── CALL CLIENTS ──────────────────────────────────────────────
|
|
1032
|
+
// Which windows can take a call over. A page announces it (CALL_CLIENT_READY)
|
|
1033
|
+
// when it has the call push listener mounted with videoCalls enabled, and again
|
|
1034
|
+
// when the worker asks (CALL_CLIENT_PROBE, sent when the worker starts, since a
|
|
1035
|
+
// restarted worker has forgotten who was ready). A host page with no <Chat>
|
|
1036
|
+
// never announces, so the worker knows, without waiting for a reply that will
|
|
1037
|
+
// not come, that handing it an Answer is pointless. Per client id; an id that
|
|
1038
|
+
// is no longer an open window is simply never looked up.
|
|
1039
|
+
const readyClients = new Map();
|
|
1040
|
+
const CALL_PROBE_GRACE_MS = 300;
|
|
1041
|
+
const workerStartedAt = Date.now();
|
|
1042
|
+
let startupWindows = 0;
|
|
1043
|
+
|
|
1044
|
+
(async () => {
|
|
1045
|
+
try {
|
|
1046
|
+
const open = await clients.matchAll({
|
|
1047
|
+
type: 'window',
|
|
1048
|
+
includeUncontrolled: true,
|
|
1049
|
+
});
|
|
1050
|
+
startupWindows = open.length;
|
|
1051
|
+
open.forEach((c) => {
|
|
1052
|
+
try {
|
|
1053
|
+
c.postMessage({ type: 'CALL_CLIENT_PROBE' });
|
|
1054
|
+
} catch {}
|
|
1055
|
+
});
|
|
1056
|
+
} catch (e) {
|
|
1057
|
+
warn('startup probe failed', e);
|
|
1058
|
+
}
|
|
1059
|
+
})();
|
|
1060
|
+
|
|
1061
|
+
function isReadyClient(client) {
|
|
1062
|
+
return !!client && !!client.id && readyClients.get(client.id) === true;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// The windows that can take a call over. Just after the worker (re)started the
|
|
1066
|
+
// pages are still answering its probe: wait out a short grace for them rather
|
|
1067
|
+
// than treat every tab as unable to handle it.
|
|
1068
|
+
async function readyWindowsOf(allClients) {
|
|
1069
|
+
let ready = allClients.filter(isReadyClient);
|
|
1070
|
+
if (!ready.length && startupWindows > 0) {
|
|
1071
|
+
const left = CALL_PROBE_GRACE_MS - (Date.now() - workerStartedAt);
|
|
1072
|
+
if (left > 0) {
|
|
1073
|
+
await sleep(left);
|
|
1074
|
+
ready = allClients.filter(isReadyClient);
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return ready;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
// Focused in-scope window first, then any in-scope, then same-origin.
|
|
1081
|
+
function orderClients(list) {
|
|
1082
|
+
const rank = (c) => (inScope(c) ? (c.focused ? 0 : 1) : sameOrigin(c) ? 2 : 3);
|
|
1083
|
+
return list
|
|
1084
|
+
.map((c, i) => ({ c, i }))
|
|
1085
|
+
.sort((a, b) => rank(a.c) - rank(b.c) || a.i - b.i)
|
|
1086
|
+
.map((x) => x.c);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function sameClient(a, b) {
|
|
1090
|
+
return !!a && !!b && (a === b || (!!a.id && a.id === b.id));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
self.addEventListener('message', (event) => {
|
|
1094
|
+
const type = event.data && event.data.type;
|
|
1095
|
+
if (type === 'CALL_PUSH_ACK') {
|
|
1096
|
+
resolveAck(
|
|
1097
|
+
'call',
|
|
1098
|
+
String(event.data.callId || ''),
|
|
1099
|
+
event.data.busy === true,
|
|
1100
|
+
event.source
|
|
1101
|
+
);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (type === 'CALL_PUSH_DECLINE_ACK') {
|
|
1105
|
+
resolveAck('decline', String(event.data.callId || ''), false, event.source);
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
if (type === 'CALL_PUSH_ANSWER_ACK') {
|
|
1109
|
+
resolveAck('answer', String(event.data.callId || ''), false, event.source);
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
if (type === 'CALL_CLIENT_READY') {
|
|
1113
|
+
const id = event.source && event.source.id;
|
|
1114
|
+
if (!id) return;
|
|
1115
|
+
if (event.data.ready === false) readyClients.delete(id);
|
|
1116
|
+
else readyClients.set(id, true);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
// A page showed a call notification through the registration. It is
|
|
1120
|
+
// persistent, and the tab that showed it may close before the ring is over:
|
|
1121
|
+
// the worker ends it (Missed call) at the token's exp like a ring of its own.
|
|
1122
|
+
if (type === 'CALL_RING_SHOWN') {
|
|
1123
|
+
const info = isPlainObject(event.data.data) ? event.data.data : {};
|
|
1124
|
+
const key = String(event.data.key || '').trim() || callKeyOf(info);
|
|
1125
|
+
if (!key) return;
|
|
1126
|
+
const exp = Number(event.data.exp);
|
|
1127
|
+
const now = Date.now();
|
|
1128
|
+
event.waitUntil(
|
|
1129
|
+
expireCallNotification({
|
|
1130
|
+
tag: `call:${key}`,
|
|
1131
|
+
data: info,
|
|
1132
|
+
endAt: Math.min(
|
|
1133
|
+
Number.isFinite(exp) && exp > 0 ? exp : Infinity,
|
|
1134
|
+
now + CALL_KEEPALIVE_CAP_MS
|
|
1135
|
+
),
|
|
1136
|
+
icon: sanitizeIconUrl(event.data.icon) || pickIcon({}, info, {}),
|
|
1137
|
+
badge: sanitizeIconUrl(event.data.badge) || pickBadge(),
|
|
1138
|
+
})
|
|
1139
|
+
);
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
if (type !== 'GET_PENDING_PUSH_CLICK') return;
|
|
1143
|
+
const source = event.source;
|
|
1144
|
+
event.waitUntil(
|
|
1145
|
+
(async () => {
|
|
1146
|
+
const payload = await takePendingClick();
|
|
1147
|
+
// discard: the page already took the click from the url (fromPush=1),
|
|
1148
|
+
// so the copy is only dropped, never replayed.
|
|
1149
|
+
if (event.data.discard === true) return;
|
|
1150
|
+
if (!payload || !source) return;
|
|
1151
|
+
try {
|
|
1152
|
+
source.postMessage({ type: 'PUSH_NOTIFICATION_CLICK', ...payload });
|
|
1153
|
+
} catch {}
|
|
1154
|
+
})()
|
|
1155
|
+
);
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
// ── CALL DECLINE ──────────────────────────────────────────────
|
|
1159
|
+
// Declining from the notification never opens a window. Every open page is
|
|
1160
|
+
// asked; the one that takes it does the work (XMPP call-state plus the REST
|
|
1161
|
+
// call, and it clears its ring) and answers CALL_PUSH_DECLINE_ACK once it has.
|
|
1162
|
+
// A window that cannot do it (a host
|
|
1163
|
+
// page with no <Chat>, the config not loaded yet) never acks, and the worker
|
|
1164
|
+
// then calls the REST decline itself after CALL_DECLINE_ACK_TIMEOUT_MS, as it
|
|
1165
|
+
// does with no page at all. It uses the user token the page mirrored into Cache
|
|
1166
|
+
// Storage (the same token the SDK already keeps in the origin's localStorage);
|
|
1167
|
+
// with no mirrored token it only closes the notification and the caller times
|
|
1168
|
+
// out.
|
|
1169
|
+
const CALL_DECLINE_ACK_TIMEOUT_MS = 1500;
|
|
1170
|
+
const CALL_AUTH_CACHE = 'ethora-push-auth-v1';
|
|
1171
|
+
const CALL_AUTH_KEY = `${APP_URL}/__ethora_call_auth__`;
|
|
1172
|
+
|
|
1173
|
+
async function readCallAuth() {
|
|
1174
|
+
try {
|
|
1175
|
+
const cache = await caches.open(CALL_AUTH_CACHE);
|
|
1176
|
+
const hit = await cache.match(CALL_AUTH_KEY);
|
|
1177
|
+
if (!hit) return null;
|
|
1178
|
+
const auth = await hit.json();
|
|
1179
|
+
return auth && auth.token && auth.apiBaseUrl ? auth : null;
|
|
1180
|
+
} catch (e) {
|
|
1181
|
+
warn('readCallAuth failed', e);
|
|
1182
|
+
return null;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function declineCallViaApi(data) {
|
|
1187
|
+
const callId = callIdOf(data);
|
|
1188
|
+
if (!callId) return;
|
|
1189
|
+
const auth = await readCallAuth();
|
|
1190
|
+
if (!auth) return;
|
|
1191
|
+
try {
|
|
1192
|
+
await fetch(
|
|
1193
|
+
`${String(auth.apiBaseUrl).replace(/\/+$/, '')}/v1/chats/call/state/decline/${encodeURIComponent(callId)}`,
|
|
1194
|
+
{ method: 'POST', headers: { Authorization: auth.token } }
|
|
1195
|
+
);
|
|
1196
|
+
} catch (e) {
|
|
1197
|
+
// Best effort: CALL_NOT_FOUND (the call is already over), an expired token
|
|
1198
|
+
// or being offline all just leave the caller to time out.
|
|
1199
|
+
warn('decline request failed', e);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
// Declining goes to EVERY open window and the first that handled it acks. Which
|
|
1204
|
+
// tab that is cannot be known here: a host page with no <Chat> cannot do it, a
|
|
1205
|
+
// tab can be mid-reload, a second tab with the same call ringing must not do it
|
|
1206
|
+
// twice (the pages settle that between themselves, see callBroadcast.ts).
|
|
1207
|
+
async function handleCallDecline(data) {
|
|
1208
|
+
const allClients = await clients.matchAll({
|
|
1209
|
+
type: 'window',
|
|
1210
|
+
includeUncontrolled: true,
|
|
1211
|
+
});
|
|
1212
|
+
if (allClients.length) {
|
|
1213
|
+
const acked = waitForAck('decline', callIdOf(data), CALL_DECLINE_ACK_TIMEOUT_MS);
|
|
1214
|
+
allClients.forEach((c) => {
|
|
1215
|
+
try {
|
|
1216
|
+
c.postMessage({ type: 'CALL_PUSH_DECLINE', data });
|
|
1217
|
+
} catch {}
|
|
1218
|
+
});
|
|
1219
|
+
if ((await acked).result === 'ack') return;
|
|
1220
|
+
log('CALL DECLINE NOT ACKED, declining over REST', { callId: callIdOf(data) });
|
|
1221
|
+
}
|
|
1222
|
+
await declineCallViaApi(data);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
// ── CALL ANSWER / TAP ─────────────────────────────────────────
|
|
1226
|
+
// A tap on a call notification, or its Answer action, must end up in a window
|
|
1227
|
+
// that can really take the call. The windows the worker knows can (see
|
|
1228
|
+
// readyClients) all get the click and the one that took it acks
|
|
1229
|
+
// (CALL_PUSH_ANSWER_ACK); the other tabs end their own ring when they learn the
|
|
1230
|
+
// call was taken (callBroadcast.ts). When nobody acks, or no open window can
|
|
1231
|
+
// take it (a host page with no <Chat>), the click is kept as a pending click and
|
|
1232
|
+
// the user is brought to the chat page, where the new document asks for it.
|
|
1233
|
+
//
|
|
1234
|
+
// Order matters because the browser only lets the worker focus or open a window
|
|
1235
|
+
// for a short time after the click (user activation, about a second). So:
|
|
1236
|
+
// - the best ready window is focused at once, before any waiting;
|
|
1237
|
+
// - with no ready window there is nothing to wait for: focus and navigate an
|
|
1238
|
+
// open tab, or open a window, right away while the click is fresh;
|
|
1239
|
+
// - after an ack from a different window than the focused one, that one is
|
|
1240
|
+
// focused too (best effort);
|
|
1241
|
+
// - after a timeout navigating a controlled tab (needs no activation) is
|
|
1242
|
+
// preferred over openWindow, which may be refused by then. A refused
|
|
1243
|
+
// openWindow leaves the pending click for the next page load (2 min TTL).
|
|
1244
|
+
const CALL_CLICK_ACK_TIMEOUT_MS = 1500;
|
|
1245
|
+
|
|
1246
|
+
// Called straight away (not from a later microtask): focus() only works while
|
|
1247
|
+
// the click's user activation is fresh.
|
|
1248
|
+
function focusClient(client) {
|
|
1249
|
+
try {
|
|
1250
|
+
if (!client || typeof client.focus !== 'function') return Promise.resolve();
|
|
1251
|
+
return Promise.resolve(client.focus()).catch((e) =>
|
|
1252
|
+
warn('client focus failed', e)
|
|
1253
|
+
);
|
|
1254
|
+
} catch (e) {
|
|
1255
|
+
warn('client focus failed', e);
|
|
1256
|
+
return Promise.resolve();
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
async function bringCallClickToApp({ allClients, readyClients: skip, payload, targetUrl }) {
|
|
1261
|
+
await savePendingClick(payload);
|
|
1262
|
+
const candidates = orderClients(allClients).filter(
|
|
1263
|
+
(c) => !skip.some((r) => sameClient(r, c)) && 'navigate' in c
|
|
1264
|
+
);
|
|
1265
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
1266
|
+
const candidate = candidates[i];
|
|
1267
|
+
const focused = focusClient(candidate);
|
|
1268
|
+
try {
|
|
1269
|
+
const navigated = await candidate.navigate(targetUrl);
|
|
1270
|
+
await focused;
|
|
1271
|
+
if (navigated && navigated !== candidate) await focusClient(navigated);
|
|
1272
|
+
return;
|
|
1273
|
+
} catch (e) {
|
|
1274
|
+
// An uncontrolled client, or a url it may not go to: try the next one.
|
|
1275
|
+
warn('client navigate failed', e);
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
await clients.openWindow(withPushParams(targetUrl, payload.data || {}));
|
|
1280
|
+
} catch (e) {
|
|
1281
|
+
warn('openWindow failed', e);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
async function handleCallClick({ notificationData, targetUrl, payload }) {
|
|
1286
|
+
const callId = callIdOf(notificationData);
|
|
1287
|
+
const allClients = await clients.matchAll({
|
|
1288
|
+
type: 'window',
|
|
1289
|
+
includeUncontrolled: true,
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
if (!allClients.length) {
|
|
1293
|
+
// Cold start: no window to message yet. Keep the payload for the page.
|
|
1294
|
+
await savePendingClick(payload);
|
|
1295
|
+
return clients.openWindow(withPushParams(targetUrl, notificationData));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
const ready = await readyWindowsOf(allClients);
|
|
1299
|
+
if (ready.length) {
|
|
1300
|
+
const best = orderClients(ready)[0];
|
|
1301
|
+
const focused = focusClient(best);
|
|
1302
|
+
const acked = waitForAck('answer', callId, CALL_CLICK_ACK_TIMEOUT_MS);
|
|
1303
|
+
allClients.forEach((c) => {
|
|
1304
|
+
try {
|
|
1305
|
+
c.postMessage({ type: 'PUSH_NOTIFICATION_CLICK', ...payload });
|
|
1306
|
+
} catch {}
|
|
1307
|
+
// Backward compatibility for any legacy listeners in app code
|
|
1308
|
+
try {
|
|
1309
|
+
c.postMessage({
|
|
1310
|
+
type: 'OPEN_FROM_PUSH',
|
|
1311
|
+
url: targetUrl,
|
|
1312
|
+
notificationData,
|
|
1313
|
+
});
|
|
1314
|
+
} catch {}
|
|
1315
|
+
});
|
|
1316
|
+
const { result, source } = await acked;
|
|
1317
|
+
await focused;
|
|
1318
|
+
if (result === 'ack') {
|
|
1319
|
+
const taker = allClients.find((c) => sameClient(c, source)) || source;
|
|
1320
|
+
if (taker && !sameClient(taker, best)) await focusClient(taker);
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
log('CALL CLICK NOT ACKED, opening the app', { callId });
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
await bringCallClickToApp({
|
|
1327
|
+
allClients,
|
|
1328
|
+
readyClients: ready,
|
|
1329
|
+
payload,
|
|
1330
|
+
targetUrl,
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
|
|
534
1334
|
// ── CLICK ─────────────────────────────────────────────────────
|
|
535
1335
|
self.addEventListener('notificationclick', (event) => {
|
|
536
1336
|
log('NOTIFICATION CLICK', event.notification);
|
|
537
1337
|
|
|
538
1338
|
event.notification.close();
|
|
539
1339
|
|
|
540
|
-
const
|
|
1340
|
+
const notificationData = event.notification?.data || {};
|
|
1341
|
+
const url = notificationData.url || CLICK_URL;
|
|
1342
|
+
const action = String(event.action || '');
|
|
1343
|
+
|
|
1344
|
+
if (action === 'decline' && isCallPushData(notificationData)) {
|
|
1345
|
+
event.waitUntil(handleCallDecline(notificationData));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
// The full data object travels to the page, call fields included.
|
|
1350
|
+
const payload = {
|
|
1351
|
+
data: notificationData,
|
|
1352
|
+
notification: {
|
|
1353
|
+
title: event.notification?.title || '',
|
|
1354
|
+
body: event.notification?.body || '',
|
|
1355
|
+
},
|
|
1356
|
+
// 'answer' on a call notification: the page raises the call and
|
|
1357
|
+
// accepts it straight away.
|
|
1358
|
+
...(action ? { action } : {}),
|
|
1359
|
+
};
|
|
1360
|
+
|
|
1361
|
+
if (isCallPushData(notificationData)) {
|
|
1362
|
+
event.waitUntil(
|
|
1363
|
+
handleCallClick({
|
|
1364
|
+
notificationData,
|
|
1365
|
+
targetUrl: normalizeUrl(url),
|
|
1366
|
+
payload,
|
|
1367
|
+
})
|
|
1368
|
+
);
|
|
1369
|
+
return;
|
|
1370
|
+
}
|
|
541
1371
|
|
|
542
1372
|
event.waitUntil(
|
|
543
1373
|
(async () => {
|
|
@@ -547,67 +1377,59 @@ self.addEventListener('notificationclick', (event) => {
|
|
|
547
1377
|
});
|
|
548
1378
|
const targetUrl = normalizeUrl(url);
|
|
549
1379
|
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
1380
|
+
const send = (client) => {
|
|
1381
|
+
try {
|
|
1382
|
+
client.postMessage({ type: 'PUSH_NOTIFICATION_CLICK', ...payload });
|
|
1383
|
+
} catch {}
|
|
1384
|
+
// Backward compatibility for any legacy listeners in app code
|
|
1385
|
+
try {
|
|
1386
|
+
client.postMessage({
|
|
1387
|
+
type: 'OPEN_FROM_PUSH',
|
|
1388
|
+
url: targetUrl,
|
|
1389
|
+
notificationData,
|
|
1390
|
+
});
|
|
1391
|
+
} catch {}
|
|
556
1392
|
};
|
|
557
1393
|
|
|
558
|
-
const preferredClient =
|
|
559
|
-
allClients.find((client) => {
|
|
560
|
-
try {
|
|
561
|
-
return client.url.startsWith(self.location.origin);
|
|
562
|
-
} catch {
|
|
563
|
-
return false;
|
|
564
|
-
}
|
|
565
|
-
}) || allClients[0];
|
|
1394
|
+
const preferredClient = pickClient(allClients);
|
|
566
1395
|
|
|
567
1396
|
if (preferredClient) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
try {
|
|
578
|
-
preferredClient.postMessage({
|
|
579
|
-
type: 'PUSH_NOTIFICATION_CLICK',
|
|
580
|
-
...payload,
|
|
581
|
-
});
|
|
582
|
-
} catch {}
|
|
583
|
-
|
|
584
|
-
// Backward compatibility for any legacy listeners in app code
|
|
1397
|
+
// Warm tap: never open a second window and never reload the user's
|
|
1398
|
+
// tab. Navigate only when the push named its own url and the tab is
|
|
1399
|
+
// not already there; the new document then asks for the pending click.
|
|
1400
|
+
if (
|
|
1401
|
+
notificationData.urlExplicit === true &&
|
|
1402
|
+
!samePage(targetUrl, preferredClient.url) &&
|
|
1403
|
+
'navigate' in preferredClient
|
|
1404
|
+
) {
|
|
1405
|
+
await savePendingClick(payload);
|
|
585
1406
|
try {
|
|
586
|
-
preferredClient.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
} catch {
|
|
1407
|
+
const navigated = await preferredClient.navigate(targetUrl);
|
|
1408
|
+
try {
|
|
1409
|
+
await (navigated || preferredClient).focus();
|
|
1410
|
+
} catch {}
|
|
1411
|
+
return;
|
|
1412
|
+
} catch (e) {
|
|
1413
|
+
// Uncontrolled client, or a url it may not go to: stay on the
|
|
1414
|
+
// page, message it directly, and drop the pending copy.
|
|
1415
|
+
warn('client navigate failed', e);
|
|
1416
|
+
await takePendingClick();
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
592
1419
|
|
|
593
|
-
|
|
1420
|
+
try {
|
|
1421
|
+
if ('focus' in preferredClient) await preferredClient.focus();
|
|
594
1422
|
} catch (e) {
|
|
595
|
-
warn('
|
|
1423
|
+
warn('client focus failed', e);
|
|
596
1424
|
}
|
|
597
|
-
}
|
|
598
1425
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
if (opened) {
|
|
602
|
-
try {
|
|
603
|
-
opened.postMessage({
|
|
604
|
-
type: 'PUSH_NOTIFICATION_CLICK',
|
|
605
|
-
...payload,
|
|
606
|
-
});
|
|
607
|
-
} catch {}
|
|
1426
|
+
send(preferredClient);
|
|
1427
|
+
return;
|
|
608
1428
|
}
|
|
609
1429
|
|
|
610
|
-
|
|
1430
|
+
// Cold start: no window to message yet. Keep the payload for the page.
|
|
1431
|
+
await savePendingClick(payload);
|
|
1432
|
+
return clients.openWindow(withPushParams(targetUrl, notificationData));
|
|
611
1433
|
})()
|
|
612
1434
|
);
|
|
613
1435
|
});
|