@ethora/chat-component 26.10.5 → 26.10.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +32 -1
  2. package/dist/{ChatProfileModal-Dw7pPDv8.js → ChatProfileModal-BDZvd0rm.js} +145 -145
  3. package/dist/{FilePreviewModal-CEcb5HZ2.js → FilePreviewModal-DlOjnYzv.js} +79 -79
  4. package/dist/{ManageDataModal-Qma0dQja.js → ManageDataModal-BZLGJZze.js} +16 -16
  5. package/dist/{MarkdownBody-DiVdEIJH.js → MarkdownBody-CtArwCDa.js} +1 -1
  6. package/dist/{MessageSearchModal-DVJTW9WM.js → MessageSearchModal-CcUnfZlU.js} +46 -46
  7. package/dist/{PublicChatsModal-CX1qZbEc.js → PublicChatsModal-BWn0NXVo.js} +10 -10
  8. package/dist/{SideDrawer-BzfNGNbq.js → SideDrawer-CqZl9sxK.js} +28 -28
  9. package/dist/UserProfileModal-D0JZ3-uy.js +635 -0
  10. package/dist/{UserSettingsModal-DdbURRR3.js → UserSettingsModal-OvykYDsT.js} +4 -4
  11. package/dist/{VideoCallOverlay-D1lGzbw3.js → VideoCallOverlay-DrvO2v_g.js} +4616 -4477
  12. package/dist/{VisibilityModal-yGZ0eOJx.js → VisibilityModal-pOF9-UXR.js} +11 -11
  13. package/dist/components/Modals/styledModalComponents.d.ts +4 -4
  14. package/dist/components/VideoCalls/VideoCallSession.d.ts +2 -0
  15. package/dist/components/styled/SendInput.d.ts +3 -2
  16. package/dist/firebase-messaging-sw.js +918 -96
  17. package/dist/{handleCopyClick-C-w304_U.js → handleCopyClick-BUbVvd2G.js} +1 -1
  18. package/dist/helpers/callClaim.d.ts +17 -0
  19. package/dist/helpers/callOutcomeSync.d.ts +23 -0
  20. package/dist/helpers/callPush.d.ts +23 -0
  21. package/dist/helpers/incomingCallPush.d.ts +89 -0
  22. package/dist/helpers/pushRoomHandoff.d.ts +13 -0
  23. package/dist/hooks/useChatSettingState.d.ts +1 -0
  24. package/dist/hooks/usePushNotifications.d.ts +2 -0
  25. package/dist/{main-Cdwk7Rjj.js → main-CpHMnydn.js} +12241 -11463
  26. package/dist/main.js +17 -17
  27. package/dist/networking/api-requests/rooms.api.d.ts +9 -0
  28. package/dist/networking/xmpp/sendMessageReaction.xmpp.d.ts +13 -1
  29. package/dist/networking/xmppClient.d.ts +2 -1
  30. package/dist/roomStore/Middleware/blockedHydrationMiddleware.d.ts +2 -0
  31. package/dist/roomStore/chatSettingsSlice.d.ts +2 -1
  32. package/dist/types/models/config.model.d.ts +42 -0
  33. package/dist/types/models/xmpp.model.d.ts +2 -1
  34. package/dist/utils/callAuthMirror.d.ts +1 -0
  35. package/dist/utils/callBroadcast.d.ts +12 -0
  36. package/dist/utils/callNotifications.d.ts +67 -0
  37. package/dist/utils/firebasePushNotifications.d.ts +14 -0
  38. package/dist/utils/handledCalls.d.ts +5 -0
  39. package/dist/utils/pushServiceWorkerBridge.d.ts +19 -0
  40. package/dist/utils/webLock.d.ts +13 -0
  41. package/package.json +1 -1
  42. package/public/firebase-messaging-sw.js +918 -96
  43. package/dist/UserProfileModal-DdW_nEsM.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) === '/' && url.charAt(1) !== '/') return url;
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 data.image || notification.image || raw.image || undefined;
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 — otherwise the app's isCallPush()
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() === 'audio'
266
- ? 'audio'
267
- : 'video';
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
- async function handlePush(raw = {}, source = 'push') {
364
- const data = raw.data || {};
365
- const notification = raw.notification || {};
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 dedupKey = makeDedupKey(raw, data, notification);
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
- ? pickField(data, ['callId', 'callid', 'callID'])
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 ? 'Tap to answer' : body;
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
- tag: isCall ? `call:${callId || dedupKey}` : roomTag(data) || dedupKey,
470
- renotify: isCall || !!roomTag(data),
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
- // Calls: a client that's merely backgrounded (open, just not the
485
- // visible/focused tab) is NOT the "nobody will hear this ring" case -
486
- // VideoCallOverlay already rings its own OS notification for exactly
487
- // that state (see its comment: this SW push is meant to be the
488
- // fully-offline fallback). `visible` collapses "backgrounded but open"
489
- // and "fully closed" into the same false, which duplicated that
490
- // client-side ring every time the tab was merely hidden. Suppress on
491
- // ANY open client for calls; regular messages keep the visible-only
492
- // check they already had.
493
- const suppress = isCall ? hasAnyClient : visible;
494
-
495
- if (!suppress) {
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: isCall ? 'NO_CLIENT_AT_ALL' : 'NO_VISIBLE_CLIENT',
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
- reason: isCall ? 'CLIENT_OPEN_WILL_RING_ITSELF' : 'VISIBLE_CLIENT',
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 url = event.notification?.data?.url || APP_URL;
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 payload = {
551
- data: event.notification?.data || {},
552
- notification: {
553
- title: event.notification?.title || '',
554
- body: event.notification?.body || '',
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
- try {
569
- if ('navigate' in preferredClient) {
570
- await preferredClient.navigate(targetUrl);
571
- }
572
-
573
- if ('focus' in preferredClient) {
574
- await preferredClient.focus();
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.postMessage({
587
- type: 'OPEN_FROM_PUSH',
588
- url: targetUrl,
589
- notificationData: event.notification?.data || {},
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
- return;
1420
+ try {
1421
+ if ('focus' in preferredClient) await preferredClient.focus();
594
1422
  } catch (e) {
595
- warn('notificationclick client handling failed', e);
1423
+ warn('client focus failed', e);
596
1424
  }
597
- }
598
1425
 
599
- const opened = await clients.openWindow(targetUrl);
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
- return opened;
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
  });