From d2c9add8002c85a78385284099e47580f3128ab8 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:27:29 -0400 Subject: [PATCH 1/2] feature: vibrate and hold urgent match-found and admin-call pushes --- components/matchmaking/MatchmakingConfirm.vue | 24 ++ composables/useCameraTalkback.ts | 20 +- composables/usePushNotifications.ts | 27 ++ i18n/locales/ar_SA.json | 10 +- i18n/locales/da_DK.json | 10 +- i18n/locales/de_DE.json | 10 +- i18n/locales/en.json | 10 +- i18n/locales/es_ES.json | 10 +- i18n/locales/fr_FR.json | 10 +- i18n/locales/it_IT.json | 10 +- i18n/locales/ja_JP.json | 10 +- i18n/locales/ko_KR.json | 10 +- i18n/locales/pl_PL.json | 10 +- i18n/locales/pt_BR.json | 10 +- i18n/locales/ru_RU.json | 10 +- i18n/locales/sv_SE.json | 10 +- i18n/locales/tr_TR.json | 10 +- i18n/locales/uk_UA.json | 10 +- i18n/locales/zh_Hans.json | 10 +- i18n/locales/zh_Hant.json | 10 +- nuxt.config.ts | 2 +- public/sw-push.js | 48 +++- tests/components/MatchmakingConfirm.spec.ts | 93 +++++++ tests/composables/closeNotifications.spec.ts | 52 ++++ tests/helpers/fakeServiceWorker.ts | 47 ++++ tests/public/swPush.spec.ts | 232 ++++++++++++++++++ utilities/chatThread.ts | 11 +- 27 files changed, 704 insertions(+), 22 deletions(-) create mode 100644 tests/components/MatchmakingConfirm.spec.ts create mode 100644 tests/composables/closeNotifications.spec.ts create mode 100644 tests/helpers/fakeServiceWorker.ts create mode 100644 tests/public/swPush.spec.ts diff --git a/components/matchmaking/MatchmakingConfirm.vue b/components/matchmaking/MatchmakingConfirm.vue index 042115465..4413944b4 100644 --- a/components/matchmaking/MatchmakingConfirm.vue +++ b/components/matchmaking/MatchmakingConfirm.vue @@ -148,12 +148,15 @@ import { useMatchmakingStore } from "~/stores/MatchmakingStore"; import { useMatchReadyModal } from "~/composables/useMatchReadyModal"; import socket from "~/web-sockets/Socket"; import { useSound } from "~/composables/useSound"; +import { closeNotifications } from "~/composables/usePushNotifications"; +import { notificationThreadKey } from "~/utilities/chatThread"; export default { data() { return { remainingSeconds: 0, routedConfirmedId: undefined as string | undefined, + silencedRingId: undefined as string | undefined, countdownInterval: undefined as NodeJS.Timeout | undefined, playCountdownSound: useSound().playCountdownSound, playMatchFoundSound: useSound().playMatchFoundSound, @@ -183,6 +186,17 @@ export default { confirmation: { immediate: true, handler(confirmation, oldConfirmation) { + if ( + oldConfirmation && + oldConfirmation.confirmationId !== confirmation?.confirmationId + ) { + this.silenceRing(oldConfirmation.confirmationId); + } + + if (confirmation?.isReady || confirmation?.matchId) { + this.silenceRing(confirmation.confirmationId); + } + if (!confirmation) { useMatchReadyModal().closeMatchReadyModal(); return; @@ -219,6 +233,16 @@ export default { confirmationId: this.confirmation.confirmationId, }); }, + silenceRing(confirmationId: string) { + if (this.silencedRingId === confirmationId) { + return; + } + + this.silencedRingId = confirmationId; + void closeNotifications( + notificationThreadKey("MatchFound", confirmationId), + ); + }, updateCountdown() { if ( this.confirmation?.expiresAt && diff --git a/composables/useCameraTalkback.ts b/composables/useCameraTalkback.ts index b8f79640a..5821eef83 100644 --- a/composables/useCameraTalkback.ts +++ b/composables/useCameraTalkback.ts @@ -1,4 +1,6 @@ -import { ref, onScopeDispose } from "vue"; +import { ref, onScopeDispose, watch } from "vue"; +import { useDocumentVisibility } from "@vueuse/core"; +import socket from "~/web-sockets/Socket"; import { cameraPlayerTalkUrl, fetchCameraTalkStatus, @@ -6,6 +8,8 @@ import { negotiateWebRtc, } from "~/composables/useCameraApi"; import { useIceServers } from "~/composables/useIceServers"; +import { closeNotifications } from "~/composables/usePushNotifications"; +import { notificationThreadKey } from "~/utilities/chatThread"; // The other direction: an organizer talking to the player whose camera this is. // Nothing here starts until the player has connected, because the connect click @@ -24,6 +28,19 @@ export function useCameraTalkback(matchId: () => string) { let disposed = false; const ice = useIceServers(); + const visibility = useDocumentVisibility(); + + const callThread = () => notificationThreadKey("AdminCall", matchId()); + + // The camera page has no layout, so no chat presence reporter runs beside + // this one. While the call is playing here, reporting it as focused lets the + // server skip ringing the player again about a call they are already on. + watch([talking, visibility], ([isTalking, visible]) => { + socket.setPresence({ + visible: visible !== "hidden", + focus: isTalking ? callThread() : null, + }); + }); // If autoplay refuses sound the promise rejects and nothing plays at all, so // fall back to a muted start -- a picture with a visible unmute beats a black @@ -68,6 +85,7 @@ export function useCameraTalkback(matchId: () => string) { await negotiateWebRtc(pc, cameraPlayerTalkUrl(matchId()), "include"); talking.value = true; + void closeNotifications(callThread()); } catch { end(); } diff --git a/composables/usePushNotifications.ts b/composables/usePushNotifications.ts index d06fd2c76..c74cdc2ff 100644 --- a/composables/usePushNotifications.ts +++ b/composables/usePushNotifications.ts @@ -92,6 +92,33 @@ async function getExistingSubscription(): Promise { return registration.pushManager.getSubscription(); } +// Takes a ring off this device once it has been answered here. Other devices +// keep theirs until their worker closes it at `expiresAt`. +// +// getRegistration() rather than serviceWorker.ready, which never settles when +// no worker is registered. +export async function closeNotifications(tag: string): Promise { + if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) { + return; + } + + try { + const registration = await navigator.serviceWorker.getRegistration(); + + if (!registration || typeof registration.getNotifications !== "function") { + return; + } + + const notifications = await registration.getNotifications({ tag }); + + for (const notification of notifications) { + notification.close(); + } + } catch { + // Best effort: the worker closes it at `expiresAt` regardless. + } +} + export function usePushNotifications() { const isDenied = computed(() => permission.value === "denied"); diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 1e2d86079..80597e37b 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -1701,6 +1701,14 @@ "title": "المباريات", "description": "تغيّرات حالة المباراة والمباريات المستوردة." }, + "match_found": { + "title": "تم العثور على مباراة", + "description": "وجد نظام المطابقة مباراتك وينتظر قبولك. يرن حتى خلال ساعات الهدوء." + }, + "admin_call": { + "title": "مكالمات المشرفين", + "description": "مشرف يريد التحدث إليك عبر كاميرا المباراة. يرن حتى خلال ساعات الهدوء." + }, "chat": { "title": "الدردشة", "description": "رسائل جديدة في محادثة أو ردهة أو غرفة بطولة." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "ساعات الهدوء", - "description": "تبقى الإشعارات الفورية صامتة خلال هذه الفترة، لكنها تظل تظهر في جرس التنبيهات.", + "description": "تبقى الإشعارات الفورية صامتة خلال هذه الفترة، لكنها تظل تظهر في جرس التنبيهات. أما العثور على مباراة ومكالمات المشرفين فترن على أي حال.", "from": "من", "to": "إلى", "reset": "إعادة الضبط", diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index 4d74d3c4c..513b942f9 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -1701,6 +1701,14 @@ "title": "Kampe", "description": "Ændringer i kampstatus og importerede kampe." }, + "match_found": { + "title": "Kamp fundet", + "description": "Matchmaking har fundet din kamp og venter på, at du accepterer. Ringer også i stilletimer." + }, + "admin_call": { + "title": "Opkald fra admins", + "description": "En admin vil tale med dig via dit kampkamera. Ringer også i stilletimer." + }, "chat": { "title": "Chat", "description": "Nye beskeder i en samtale, en lobby eller et turneringsrum." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Stilletimer", - "description": "Push-notifikationer forbliver lydløse i dette tidsrum. De vises stadig i din notifikationsklokke.", + "description": "Push-notifikationer forbliver lydløse i dette tidsrum. De vises stadig i din notifikationsklokke. Fundne kampe og opkald fra admins ringer alligevel.", "from": "Fra", "to": "Til", "reset": "Nulstil", diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index 8fb516d08..032d7dcea 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -1701,6 +1701,14 @@ "title": "Matches", "description": "Änderungen des Matchstatus und importierte Matches." }, + "match_found": { + "title": "Match gefunden", + "description": "Das Matchmaking hat dein Match gefunden und wartet darauf, dass du annimmst. Klingelt auch während der Ruhezeiten." + }, + "admin_call": { + "title": "Admin-Anrufe", + "description": "Ein Admin möchte über deine Match-Kamera mit dir sprechen. Klingelt auch während der Ruhezeiten." + }, "chat": { "title": "Chat", "description": "Neue Nachrichten in einer Unterhaltung, Lobby oder einem Turnierraum." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Ruhezeiten", - "description": "Push-Benachrichtigungen bleiben in diesem Zeitfenster stumm. In deiner Benachrichtigungsglocke erscheinen sie weiterhin.", + "description": "Push-Benachrichtigungen bleiben in diesem Zeitfenster stumm. In deiner Benachrichtigungsglocke erscheinen sie weiterhin. Gefundene Matches und Admin-Anrufe klingeln trotzdem.", "from": "Von", "to": "Bis", "reset": "Zurücksetzen", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index cef42bc2c..e65c99d32 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1701,6 +1701,14 @@ "title": "Matches", "description": "Match status changes and imported matches." }, + "match_found": { + "title": "Match found", + "description": "Matchmaking found your match and is waiting for you to accept. Rings even during quiet hours." + }, + "admin_call": { + "title": "Admin calls", + "description": "An admin wants to talk to you over your match camera. Rings even during quiet hours." + }, "chat": { "title": "Chat", "description": "New messages in a conversation, lobby or tournament room." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Quiet Hours", - "description": "Push notifications stay silent during this window. They still appear in your alert bell.", + "description": "Push notifications stay silent during this window. They still appear in your alert bell. Match found and admin calls ring anyway.", "from": "From", "to": "To", "reset": "Reset", diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index af85d643e..b4045d1cc 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -1701,6 +1701,14 @@ "title": "Partidas", "description": "Cambios de estado de partida y partidas importadas." }, + "match_found": { + "title": "Partida encontrada", + "description": "El emparejamiento ha encontrado tu partida y espera a que la aceptes. Suena incluso en horas de silencio." + }, + "admin_call": { + "title": "Llamadas de administradores", + "description": "Un administrador quiere hablar contigo a través de la cámara de tu partida. Suena incluso en horas de silencio." + }, "chat": { "title": "Chat", "description": "Mensajes nuevos en una conversación, un lobby o una sala de torneo." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Horas de silencio", - "description": "Las notificaciones push permanecen en silencio durante este intervalo. Siguen apareciendo en tu campana de avisos.", + "description": "Las notificaciones push permanecen en silencio durante este intervalo. Siguen apareciendo en tu campana de avisos. Las partidas encontradas y las llamadas de administradores suenan igualmente.", "from": "Desde", "to": "Hasta", "reset": "Restablecer", diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index 333a92a08..95acabf5a 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -1701,6 +1701,14 @@ "title": "Matchs", "description": "Changements de statut de match et matchs importés." }, + "match_found": { + "title": "Match trouvé", + "description": "Le matchmaking a trouvé votre match et attend que vous acceptiez. Sonne même pendant les heures silencieuses." + }, + "admin_call": { + "title": "Appels d'administrateurs", + "description": "Un administrateur veut vous parler via la caméra de votre match. Sonne même pendant les heures silencieuses." + }, "chat": { "title": "Chat", "description": "Nouveaux messages dans une conversation, un lobby ou un salon de tournoi." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Heures silencieuses", - "description": "Les notifications push restent silencieuses pendant cette plage. Elles apparaissent toujours dans votre cloche d'alertes.", + "description": "Les notifications push restent silencieuses pendant cette plage. Elles apparaissent toujours dans votre cloche d'alertes. Les matchs trouvés et les appels d'administrateurs sonnent quand même.", "from": "De", "to": "À", "reset": "Réinitialiser", diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index 00ef4b2ec..7a65144f6 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -1701,6 +1701,14 @@ "title": "Partite", "description": "Cambi di stato delle partite e partite importate." }, + "match_found": { + "title": "Match trovato", + "description": "Il matchmaking ha trovato la tua partita e aspetta che tu accetti. Suona anche nelle ore di silenzio." + }, + "admin_call": { + "title": "Chiamate degli admin", + "description": "Un admin vuole parlarti tramite la videocamera della partita. Suona anche nelle ore di silenzio." + }, "chat": { "title": "Chat", "description": "Nuovi messaggi in una conversazione, una lobby o una sala torneo." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Ore di silenzio", - "description": "Le notifiche push restano silenziose in questa fascia. Compaiono comunque nella tua campanella degli avvisi.", + "description": "Le notifiche push restano silenziose in questa fascia. Compaiono comunque nella tua campanella degli avvisi. Le partite trovate e le chiamate degli admin suonano in ogni caso.", "from": "Da", "to": "A", "reset": "Reimposta", diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index b91063c3a..426131c3b 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -1701,6 +1701,14 @@ "title": "試合", "description": "試合状況の変化とインポートされた試合。" }, + "match_found": { + "title": "マッチ成立", + "description": "マッチメイキングで試合が見つかり、承認を待っています。サイレント時間中も鳴ります。" + }, + "admin_call": { + "title": "管理者からの呼び出し", + "description": "管理者が試合用カメラを通じてあなたと話したがっています。サイレント時間中も鳴ります。" + }, "chat": { "title": "チャット", "description": "会話、ロビー、トーナメントルームの新着メッセージ。" @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "サイレント時間", - "description": "この時間帯はプッシュ通知が鳴りません。通知ベルには引き続き表示されます。", + "description": "この時間帯はプッシュ通知が鳴りません。通知ベルには引き続き表示されます。マッチ成立と管理者からの呼び出しは例外として鳴ります。", "from": "開始", "to": "終了", "reset": "リセット", diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 2d41ab6c0..848c93430 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -1701,6 +1701,14 @@ "title": "경기", "description": "경기 상태 변경 및 가져온 경기." }, + "match_found": { + "title": "매치 찾음", + "description": "매치메이킹이 매치를 찾았고 수락을 기다리고 있습니다. 방해 금지 시간에도 울립니다." + }, + "admin_call": { + "title": "관리자 호출", + "description": "관리자가 경기 카메라로 대화를 요청했습니다. 방해 금지 시간에도 울립니다." + }, "chat": { "title": "채팅", "description": "대화, 로비 또는 토너먼트 방의 새 메시지." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "방해 금지 시간", - "description": "이 시간대에는 푸시 알림이 소리 없이 전달됩니다. 알림 종에는 계속 표시됩니다.", + "description": "이 시간대에는 푸시 알림이 소리 없이 전달됩니다. 알림 종에는 계속 표시됩니다. 매치 찾음과 관리자 호출은 예외로 울립니다.", "from": "시작", "to": "종료", "reset": "초기화", diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index c3931b1ca..9cb5991ad 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -1701,6 +1701,14 @@ "title": "Mecze", "description": "Zmiany statusu meczu i zaimportowane mecze." }, + "match_found": { + "title": "Znaleziono mecz", + "description": "Matchmaking znalazł twój mecz i czeka, aż go zaakceptujesz. Dzwoni nawet w godzinach ciszy." + }, + "admin_call": { + "title": "Połączenia od administratorów", + "description": "Administrator chce porozmawiać z tobą przez kamerę meczu. Dzwoni nawet w godzinach ciszy." + }, "chat": { "title": "Czat", "description": "Nowe wiadomości w rozmowie, lobby lub pokoju turniejowym." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Godziny ciszy", - "description": "W tym przedziale powiadomienia push pozostają wyciszone. Nadal pojawiają się w dzwonku powiadomień.", + "description": "W tym przedziale powiadomienia push pozostają wyciszone. Nadal pojawiają się w dzwonku powiadomień. Znalezione mecze i połączenia od administratorów dzwonią mimo to.", "from": "Od", "to": "Do", "reset": "Resetuj", diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index 55e179c90..6aa0c936f 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -1701,6 +1701,14 @@ "title": "Partidas", "description": "Mudanças de status de partidas e partidas importadas." }, + "match_found": { + "title": "Partida encontrada", + "description": "O matchmaking encontrou sua partida e está esperando você aceitar. Toca mesmo durante o horário silencioso." + }, + "admin_call": { + "title": "Chamadas de administradores", + "description": "Um administrador quer falar com você pela câmera da partida. Toca mesmo durante o horário silencioso." + }, "chat": { "title": "Chat", "description": "Novas mensagens em um lobby, partida ou conversa." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Horário silencioso", - "description": "Notificações push permanecem silenciosas durante este período. Elas ainda aparecem no sino de alertas.", + "description": "Notificações push permanecem silenciosas durante este período. Elas ainda aparecem no sino de alertas. Partidas encontradas e chamadas de administradores tocam mesmo assim.", "from": "De", "to": "Até", "reset": "Redefinir", diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index 357336756..549f0012e 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -1701,6 +1701,14 @@ "title": "Матчи", "description": "Изменения статуса матча и импортированные матчи." }, + "match_found": { + "title": "Матч найден", + "description": "Подбор нашёл ваш матч и ждёт вашего подтверждения. Звонит даже в тихие часы." + }, + "admin_call": { + "title": "Вызовы администраторов", + "description": "Администратор хочет поговорить с вами через камеру матча. Звонит даже в тихие часы." + }, "chat": { "title": "Чат", "description": "Новые сообщения в переписке, лобби или турнирной комнате." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Тихие часы", - "description": "В этом промежутке push-уведомления остаются беззвучными. В колокольчике оповещений они всё равно появляются.", + "description": "В этом промежутке push-уведомления остаются беззвучными. В колокольчике оповещений они всё равно появляются. Найденные матчи и вызовы администраторов звонят в любом случае.", "from": "С", "to": "До", "reset": "Сбросить", diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index 640d25526..6455477c2 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -1701,6 +1701,14 @@ "title": "Matcher", "description": "Ändringar av matchstatus och importerade matcher." }, + "match_found": { + "title": "Match hittad", + "description": "Matchmaking har hittat din match och väntar på att du accepterar. Ringer även under tysta timmar." + }, + "admin_call": { + "title": "Samtal från admins", + "description": "En admin vill prata med dig via din matchkamera. Ringer även under tysta timmar." + }, "chat": { "title": "Chatt", "description": "Nya meddelanden i en konversation, ett lobby eller ett turneringsrum." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Tysta timmar", - "description": "Push-aviseringar förblir ljudlösa under det här intervallet. De visas fortfarande i din aviseringsklocka.", + "description": "Push-aviseringar förblir ljudlösa under det här intervallet. De visas fortfarande i din aviseringsklocka. Hittade matcher och samtal från admins ringer ändå.", "from": "Från", "to": "Till", "reset": "Återställ", diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index c2036adee..8dead220d 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -1701,6 +1701,14 @@ "title": "Maçlar", "description": "Maç durumu değişiklikleri ve içe aktarılan maçlar." }, + "match_found": { + "title": "Maç bulundu", + "description": "Eşleştirme maçını buldu ve kabul etmeni bekliyor. Sessiz saatlerde bile çalar." + }, + "admin_call": { + "title": "Yönetici çağrıları", + "description": "Bir yönetici maç kameran üzerinden seninle konuşmak istiyor. Sessiz saatlerde bile çalar." + }, "chat": { "title": "Sohbet", "description": "Bir konuşmada, lobide veya turnuva odasında yeni mesajlar." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Sessiz saatler", - "description": "Bu aralıkta anlık bildirimler sessiz kalır. Bildirim zilinde yine de görünürler.", + "description": "Bu aralıkta anlık bildirimler sessiz kalır. Bildirim zilinde yine de görünürler. Maç bulundu bildirimleri ve yönetici çağrıları her durumda çalar.", "from": "Başlangıç", "to": "Bitiş", "reset": "Sıfırla", diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index b8348405b..2b0ac700a 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -1701,6 +1701,14 @@ "title": "Матчі", "description": "Зміни статусу матчу та імпортовані матчі." }, + "match_found": { + "title": "Матч знайдено", + "description": "Підбір знайшов ваш матч і чекає на ваше підтвердження. Дзвонить навіть у тихі години." + }, + "admin_call": { + "title": "Виклики адміністраторів", + "description": "Адміністратор хоче поговорити з вами через камеру матчу. Дзвонить навіть у тихі години." + }, "chat": { "title": "Чат", "description": "Нові повідомлення в розмові, лобі чи турнірній кімнаті." @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "Тихі години", - "description": "У цьому проміжку push-сповіщення залишаються беззвучними. У дзвіночку сповіщень вони все одно з'являються.", + "description": "У цьому проміжку push-сповіщення залишаються беззвучними. У дзвіночку сповіщень вони все одно з'являються. Знайдені матчі та виклики адміністраторів дзвонять у будь-якому разі.", "from": "З", "to": "До", "reset": "Скинути", diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index 4669b5e94..53582cd21 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -1701,6 +1701,14 @@ "title": "比赛", "description": "比赛状态变化和导入的比赛。" }, + "match_found": { + "title": "找到比赛", + "description": "匹配系统已为你找到比赛,正在等待你接受。即使在免打扰时段也会响铃。" + }, + "admin_call": { + "title": "管理员呼叫", + "description": "管理员想通过你的比赛摄像头与你通话。即使在免打扰时段也会响铃。" + }, "chat": { "title": "聊天", "description": "会话、大厅或锦标赛房间中的新消息。" @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "免打扰时段", - "description": "在这段时间内推送通知不会发出声音,但仍会出现在你的通知铃铛中。", + "description": "在这段时间内推送通知不会发出声音,但仍会出现在你的通知铃铛中。找到比赛和管理员呼叫仍会照常响铃。", "from": "开始", "to": "结束", "reset": "重置", diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index 630544d18..30ec48cfa 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -1701,6 +1701,14 @@ "title": "比賽", "description": "比賽狀態變更與匯入的比賽。" }, + "match_found": { + "title": "找到比賽", + "description": "配對系統已為你找到比賽,正在等待你接受。即使在勿擾時段也會響鈴。" + }, + "admin_call": { + "title": "管理員呼叫", + "description": "管理員想透過你的比賽攝影機與你通話。即使在勿擾時段也會響鈴。" + }, "chat": { "title": "聊天", "description": "對話、大廳或錦標賽房間中的新訊息。" @@ -1824,7 +1832,7 @@ }, "quiet_hours": { "title": "勿擾時段", - "description": "在這段時間內推播通知不會發出聲音,但仍會出現在你的通知鈴鐺中。", + "description": "在這段時間內推播通知不會發出聲音,但仍會出現在你的通知鈴鐺中。找到比賽和管理員呼叫仍會照常響鈴。", "from": "開始", "to": "結束", "reset": "重設", diff --git a/nuxt.config.ts b/nuxt.config.ts index 806863ad8..6c5ea23d2 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -245,7 +245,7 @@ export default defineNuxtConfig({ // Adds `push` / `notificationclick` handlers to the generated service // worker without giving up the default generateSW strategy for a custom // SW file. Served unhashed from public/, so bump the ?v= on any change. - importScripts: ["/sw-push.js?v=4"], + importScripts: ["/sw-push.js?v=5"], cleanupOutdatedCaches: true, maximumFileSizeToCacheInBytes: 10 * 1024 * 1024, // Do not precache every Nuxt chunk during service-worker install. diff --git a/public/sw-push.js b/public/sw-push.js index b1fd210f8..cda531c5a 100644 --- a/public/sw-push.js +++ b/public/sw-push.js @@ -69,6 +69,48 @@ const setAppBadge = async (unread) => { } }; +const RING_VIBRATION = [300, 100, 300, 100, 300]; + +// A device clock running behind the server's reads `expiresAt` as further away +// than it is, so the wait is capped rather than trusted. +const MAX_RING_MS = 60_000; + +const expiryDelayOf = (expiresAt) => { + if (typeof expiresAt !== "string") { + return null; + } + + const at = Date.parse(expiresAt); + + if (!Number.isFinite(at)) { + return null; + } + + return Math.min(Math.max(0, at - Date.now()), MAX_RING_MS); +}; + +// Matched on `expiresAt` as well as the tag: a newer push with the same tag has +// already replaced this one on screen, and it has its own expiry to keep. +const closeAtExpiry = async (payload) => { + const delay = expiryDelayOf(payload.expiresAt); + + if (delay === null) { + return; + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + + const notifications = await self.registration.getNotifications( + payload.tag ? { tag: payload.tag } : {}, + ); + + for (const notification of notifications) { + if (notification.data?.expiresAt === payload.expiresAt) { + notification.close(); + } + } +}; + self.addEventListener("push", (event) => { let payload = {}; @@ -98,16 +140,20 @@ self.addEventListener("push", (event) => { // without this the summary that closes a burst arrives unannounced. // Ignored unless `tag` is set, and throws in Chrome without one. renotify: payload.renotify !== false && Boolean(payload.tag), + ...(payload.urgent + ? { requireInteraction: true, vibrate: RING_VIBRATION } + : {}), data: { url: payload.url || "/", threadKey: payload.threadKey || payload.tag, count: payload.count || 1, actions, graphqlUrl: trustedGraphqlUrl(payload.graphqlUrl), + expiresAt: payload.expiresAt, }, }), setAppBadge(payload.unread), - ]), + ]).then(() => closeAtExpiry(payload)), ); }); diff --git a/tests/components/MatchmakingConfirm.spec.ts b/tests/components/MatchmakingConfirm.spec.ts new file mode 100644 index 000000000..3e10e8244 --- /dev/null +++ b/tests/components/MatchmakingConfirm.spec.ts @@ -0,0 +1,93 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { mountSuspended } from "@nuxt/test-utils/runtime"; +import MatchmakingConfirm from "~/components/matchmaking/MatchmakingConfirm.vue"; +import { useMatchmakingStore } from "~/stores/MatchmakingStore"; +import { fakeServiceWorker } from "../helpers/fakeServiceWorker"; + +vi.mock("~/composables/useSound", () => ({ + useSound: () => ({ + playCountdownSound: vi.fn(), + playMatchFoundSound: vi.fn(), + playTickSound: vi.fn(), + }), +})); + +function confirmation(overrides: Record = {}) { + return { + matchId: undefined, + isReady: false, + expiresAt: new Date(Date.now() + 30_000).toISOString(), + confirmed: 3, + confirmationId: "c-1", + type: "Competitive", + region: "USE", + players: 10, + ...overrides, + } as any; +} + +function setConfirmation(value: ReturnType | undefined) { + useMatchmakingStore().joinedMatchmakingQueues = { + details: undefined, + confirmation: value, + }; +} + +let worker: ReturnType; +let wrapper: Awaited> | undefined; + +beforeEach(() => { + worker = fakeServiceWorker({ + tags: ["MatchFound:c-1", "MatchFound:c-2", "chat:lobby:abc"], + }); + setConfirmation(undefined); +}); + +afterEach(() => { + wrapper?.unmount(); + wrapper = undefined; + worker.restore(); +}); + +describe("MatchmakingConfirm ring", () => { + it("keeps ringing until the player accepts, then closes only this ready check's ring", async () => { + setConfirmation(confirmation()); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); + + setConfirmation(confirmation({ confirmed: 4 })); + await flushPromises(); + + expect(worker.closed()).toEqual([]); + + setConfirmation(confirmation({ confirmed: 5, isReady: true })); + await flushPromises(); + + expect(worker.closed()).toEqual(["MatchFound:c-1"]); + + setConfirmation(confirmation({ confirmed: 6, isReady: true })); + await flushPromises(); + + expect(worker.registration.getNotifications).toHaveBeenCalledTimes(1); + }); + + it("closes the ring when the ready check ends without the player", async () => { + setConfirmation(confirmation()); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); + + setConfirmation(undefined); + await flushPromises(); + + expect(worker.closed()).toEqual(["MatchFound:c-1"]); + }); + + it("closes the ring on mount when the player has already accepted", async () => { + setConfirmation(confirmation({ confirmationId: "c-2", isReady: true })); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); + + expect(worker.closed()).toEqual(["MatchFound:c-2"]); + }); +}); diff --git a/tests/composables/closeNotifications.spec.ts b/tests/composables/closeNotifications.spec.ts new file mode 100644 index 000000000..7ecd0f641 --- /dev/null +++ b/tests/composables/closeNotifications.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { closeNotifications } from "~/composables/usePushNotifications"; +import { fakeServiceWorker } from "../helpers/fakeServiceWorker"; + +let worker: ReturnType | undefined; + +afterEach(() => { + worker?.restore(); + worker = undefined; +}); + +describe("closeNotifications", () => { + it("closes every notification with the tag and nothing else", async () => { + worker = fakeServiceWorker({ + tags: ["MatchFound:c-1", "MatchFound:c-1", "chat:lobby:abc"], + }); + + await closeNotifications("MatchFound:c-1"); + + expect(worker.registration.getNotifications).toHaveBeenCalledWith({ + tag: "MatchFound:c-1", + }); + expect(worker.closed()).toEqual(["MatchFound:c-1", "MatchFound:c-1"]); + }); + + it("does nothing where there is no service worker", async () => { + expect("serviceWorker" in navigator).toBe(false); + + await expect(closeNotifications("MatchFound:c-1")).resolves.toBeUndefined(); + }); + + it("does nothing when no worker is registered", async () => { + worker = fakeServiceWorker({ + tags: ["MatchFound:c-1"], + registered: false, + }); + + await closeNotifications("MatchFound:c-1"); + + expect(worker.registration.getNotifications).not.toHaveBeenCalled(); + expect(worker.closed()).toEqual([]); + }); + + it("swallows a platform that refuses to list notifications", async () => { + worker = fakeServiceWorker({ tags: ["MatchFound:c-1"] }); + worker.registration.getNotifications.mockRejectedValueOnce( + new Error("not allowed"), + ); + + await expect(closeNotifications("MatchFound:c-1")).resolves.toBeUndefined(); + }); +}); diff --git a/tests/helpers/fakeServiceWorker.ts b/tests/helpers/fakeServiceWorker.ts new file mode 100644 index 000000000..307bcdcd4 --- /dev/null +++ b/tests/helpers/fakeServiceWorker.ts @@ -0,0 +1,47 @@ +import { vi } from "vitest"; + +export type FakeNotification = { + tag: string; + close: ReturnType; +}; + +export function fakeServiceWorker({ + tags = [], + registered = true, +}: { + tags?: string[]; + registered?: boolean; +} = {}) { + const notifications: FakeNotification[] = tags.map((tag) => ({ + tag, + close: vi.fn(), + })); + + const registration = { + getNotifications: vi.fn(async ({ tag }: { tag?: string } = {}) => + notifications.filter((notification) => !tag || notification.tag === tag), + ), + }; + + const serviceWorker = { + getRegistration: vi.fn(async () => (registered ? registration : undefined)), + }; + + Object.defineProperty(navigator, "serviceWorker", { + configurable: true, + value: serviceWorker, + }); + + return { + notifications, + registration, + serviceWorker, + closed: () => + notifications + .filter((notification) => notification.close.mock.calls.length > 0) + .map(({ tag }) => tag), + restore() { + delete (navigator as { serviceWorker?: unknown }).serviceWorker; + }, + }; +} diff --git a/tests/public/swPush.spec.ts b/tests/public/swPush.spec.ts new file mode 100644 index 000000000..6172ceec2 --- /dev/null +++ b/tests/public/swPush.spec.ts @@ -0,0 +1,232 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const source = readFileSync( + resolve(__dirname, "../../public/sw-push.js"), + "utf8", +); + +type Shown = { + title: string; + tag?: string; + data: Record; + options: Record; + close: ReturnType; +}; + +function loadWorker() { + const listeners: Record void> = {}; + let onScreen: Shown[] = []; + + const registration = { + showNotification: vi.fn( + async (title: string, options: Record) => { + const shown: Shown = { + title, + tag: options.tag, + data: options.data, + options, + close: vi.fn(() => { + onScreen = onScreen.filter((entry) => entry !== shown); + }), + }; + + onScreen = [ + ...onScreen.filter( + (entry) => !options.tag || entry.tag !== options.tag, + ), + shown, + ]; + }, + ), + getNotifications: vi.fn(async (filter: { tag?: string } = {}) => + onScreen.filter((entry) => !filter.tag || entry.tag === filter.tag), + ), + }; + + const self = { + registration, + location: { hostname: "5stack.gg" }, + clients: { matchAll: vi.fn(async () => []), openWindow: vi.fn() }, + addEventListener: (type: string, listener: (event: unknown) => void) => { + listeners[type] = listener; + }, + }; + + new Function("self", "navigator", source)(self, {}); + + const push = (payload: Record) => { + let settled = false; + let done: Promise = Promise.resolve(); + + listeners.push({ + data: { json: () => payload, text: () => JSON.stringify(payload) }, + waitUntil: (promise: Promise) => { + done = promise.then(() => { + settled = true; + }); + }, + }); + + return { done, isSettled: () => settled }; + }; + + return { + registration, + push, + onScreen: () => onScreen, + }; +} + +const NOW = new Date("2026-09-28T12:00:00.000Z"); + +const inSeconds = (seconds: number) => + new Date(NOW.getTime() + seconds * 1000).toISOString(); + +const matchFound = (overrides: Record = {}) => ({ + title: "Match found", + body: "Your Competitive match is ready — accept within 30s", + tag: "MatchFound:confirmation-1", + threadKey: "MatchFound:confirmation-1", + url: "/play", + renotify: true, + actions: [], + urgent: true, + ttl: 30, + expiresAt: inSeconds(30), + ...overrides, +}); + +const chatMessage = { + title: "Luke", + body: "gg", + tag: "chat:lobby:abc", + threadKey: "chat:lobby:abc", + url: "/chat/abc", + renotify: true, + actions: [], +}; + +describe("sw-push", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("rings an urgent push: vibrates and stays on screen", async () => { + const worker = loadWorker(); + + worker.push(matchFound()); + await vi.advanceTimersByTimeAsync(0); + + const [title, options] = worker.registration.showNotification.mock.calls[0]; + + expect(title).toBe("Match found"); + expect(options).toMatchObject({ + tag: "MatchFound:confirmation-1", + renotify: true, + requireInteraction: true, + vibrate: [300, 100, 300, 100, 300], + data: { url: "/play", threadKey: "MatchFound:confirmation-1" }, + }); + }); + + it("leaves every other push as it was", async () => { + const worker = loadWorker(); + + worker.push(chatMessage); + await vi.advanceTimersByTimeAsync(0); + + const [, options] = worker.registration.showNotification.mock.calls[0]; + + expect(options).not.toHaveProperty("requireInteraction"); + expect(options).not.toHaveProperty("vibrate"); + expect(options).toMatchObject({ tag: "chat:lobby:abc", renotify: true }); + }); + + it("closes the notification at expiresAt and holds the event open until then", async () => { + const worker = loadWorker(); + + const event = worker.push(matchFound()); + await vi.advanceTimersByTimeAsync(29_999); + + expect(worker.onScreen()).toHaveLength(1); + expect(event.isSettled()).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await event.done; + + expect(worker.onScreen()).toHaveLength(0); + expect(worker.registration.getNotifications).toHaveBeenCalledWith({ + tag: "MatchFound:confirmation-1", + }); + }); + + it("sets no timer without expiresAt", async () => { + const worker = loadWorker(); + + const event = worker.push(chatMessage); + await event.done; + + expect(event.isSettled()).toBe(true); + expect(vi.getTimerCount()).toBe(0); + expect(worker.registration.getNotifications).not.toHaveBeenCalled(); + expect(worker.onScreen()).toHaveLength(1); + }); + + it("caps the wait when the device clock runs behind the server's", async () => { + const worker = loadWorker(); + + worker.push(matchFound({ expiresAt: inSeconds(600) })); + await vi.advanceTimersByTimeAsync(59_999); + + expect(worker.onScreen()).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); + + expect(worker.onScreen()).toHaveLength(0); + }); + + it("closes straight away when expiresAt has already passed", async () => { + const worker = loadWorker(); + + const event = worker.push(matchFound({ expiresAt: inSeconds(-5) })); + await vi.advanceTimersByTimeAsync(0); + await event.done; + + expect(worker.onScreen()).toHaveLength(0); + }); + + it("does not let a superseded push close the one that replaced it", async () => { + const worker = loadWorker(); + + worker.push(matchFound()); + await vi.advanceTimersByTimeAsync(10_000); + worker.push(matchFound({ body: "again", expiresAt: inSeconds(40) })); + await vi.advanceTimersByTimeAsync(20_000); + + expect(worker.onScreen().map(({ options }) => options.body)).toEqual([ + "again", + ]); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(worker.onScreen()).toHaveLength(0); + }); + + it("ignores an expiresAt that is not a timestamp", async () => { + const worker = loadWorker(); + + const event = worker.push(matchFound({ expiresAt: "soon" })); + await event.done; + + expect(vi.getTimerCount()).toBe(0); + expect(worker.onScreen()).toHaveLength(1); + }); +}); diff --git a/utilities/chatThread.ts b/utilities/chatThread.ts index 3cece98a0..58270772e 100644 --- a/utilities/chatThread.ts +++ b/utilities/chatThread.ts @@ -1,5 +1,5 @@ -// The thread key, which has to match the server's chatThreadKey exactly -- see -// api/src/notifications/push/notification-delivery.ts. +// Thread keys, which have to match the server's chatThreadKey and threadKeyFor +// exactly -- see api/src/notifications/push/notification-delivery.ts. // // The same string is the read cursor, the device's notification tag and what a // visible tab reports as focused. A mismatch does not fail loudly; it just @@ -12,3 +12,10 @@ export function chatThreadKey(type: string, lobbyId: string) { return `chat:${type}:${lobbyId}`; } + +export function notificationThreadKey( + type: string, + entityId: string | null | undefined, +) { + return `${type}:${entityId ?? ""}`; +} From 42ff1ca50beb5e320f659404dbddee6ed2652db4 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:43:49 -0400 Subject: [PATCH 2/2] bug: keep the ring visible when the push event settles, re-close late ready-check rings, only hang up a joined call --- components/matchmaking/MatchmakingConfirm.vue | 9 +- composables/useCameraTalkback.ts | 32 +++++-- i18n/locales/en.json | 2 +- i18n/locales/ko_KR.json | 2 +- i18n/locales/uk_UA.json | 2 +- public/sw-push.js | 35 ++++--- tests/components/MatchmakingConfirm.spec.ts | 45 ++++++++- tests/composables/useCameraTalkback.spec.ts | 91 +++++++++++++++++++ tests/helpers/fakeServiceWorker.ts | 5 + tests/public/swPush.spec.ts | 37 ++++++-- 10 files changed, 220 insertions(+), 40 deletions(-) create mode 100644 tests/composables/useCameraTalkback.spec.ts diff --git a/components/matchmaking/MatchmakingConfirm.vue b/components/matchmaking/MatchmakingConfirm.vue index 4413944b4..b35fa22e8 100644 --- a/components/matchmaking/MatchmakingConfirm.vue +++ b/components/matchmaking/MatchmakingConfirm.vue @@ -156,7 +156,6 @@ export default { return { remainingSeconds: 0, routedConfirmedId: undefined as string | undefined, - silencedRingId: undefined as string | undefined, countdownInterval: undefined as NodeJS.Timeout | undefined, playCountdownSound: useSound().playCountdownSound, playMatchFoundSound: useSound().playMatchFoundSound, @@ -233,12 +232,10 @@ export default { confirmationId: this.confirmation.confirmationId, }); }, + // On every update rather than once: the push is sent after the socket + // update that opened the ready check, so it can land here after the + // player has already accepted. silenceRing(confirmationId: string) { - if (this.silencedRingId === confirmationId) { - return; - } - - this.silencedRingId = confirmationId; void closeNotifications( notificationThreadKey("MatchFound", confirmationId), ); diff --git a/composables/useCameraTalkback.ts b/composables/useCameraTalkback.ts index 5821eef83..be154c28a 100644 --- a/composables/useCameraTalkback.ts +++ b/composables/useCameraTalkback.ts @@ -26,6 +26,10 @@ export function useCameraTalkback(matchId: () => string) { // clearing the timer does nothing to a request that is about to re-arm it, so // the page kept polling -- and could still open a peer connection -- forever. let disposed = false; + // Hanging up kicks every session on the talk path, the organizer's included. + // A tab that never connected -- opened from the ring on a device that is not + // the camera -- must not end a call the player is taking on the one that is. + let started = false; const ice = useIceServers(); const visibility = useDocumentVisibility(); @@ -33,14 +37,20 @@ export function useCameraTalkback(matchId: () => string) { const callThread = () => notificationThreadKey("AdminCall", matchId()); // The camera page has no layout, so no chat presence reporter runs beside - // this one. While the call is playing here, reporting it as focused lets the - // server skip ringing the player again about a call they are already on. - watch([talking, visibility], ([isTalking, visible]) => { - socket.setPresence({ - visible: visible !== "hidden", - focus: isTalking ? callThread() : null, - }); - }); + // this one, and whatever the previous page last reported would otherwise + // keep going out on every heartbeat. While the call is playing here, + // reporting it as focused lets the server skip ringing the player again + // about a call they are already on. + watch( + [talking, visibility], + ([isTalking, visible]) => { + socket.setPresence({ + visible: visible !== "hidden", + focus: isTalking ? callThread() : null, + }); + }, + { immediate: true }, + ); // If autoplay refuses sound the promise rejects and nothing plays at all, so // fall back to a muted start -- a picture with a visible unmute beats a black @@ -128,6 +138,7 @@ export function useCameraTalkback(matchId: () => string) { } function start() { + started = true; void poll(); } @@ -139,7 +150,10 @@ export function useCameraTalkback(matchId: () => string) { } end(); - void hangupPlayerTalk(matchId()); + + if (started) { + void hangupPlayerTalk(matchId()); + } }); return { talkEl, talking, muted, start, toggleAudio, end }; diff --git a/i18n/locales/en.json b/i18n/locales/en.json index e65c99d32..b23db289b 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1832,7 +1832,7 @@ }, "quiet_hours": { "title": "Quiet Hours", - "description": "Push notifications stay silent during this window. They still appear in your alert bell. Match found and admin calls ring anyway.", + "description": "Push notifications stay silent during this window. They still appear in your alert bell. Match-found alerts and admin calls ring anyway.", "from": "From", "to": "To", "reset": "Reset", diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 848c93430..c659c40cf 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -1707,7 +1707,7 @@ }, "admin_call": { "title": "관리자 호출", - "description": "관리자가 경기 카메라로 대화를 요청했습니다. 방해 금지 시간에도 울립니다." + "description": "관리자가 경기 카메라로 대화하고 싶어 합니다. 방해 금지 시간에도 울립니다." }, "chat": { "title": "채팅", diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index 2b0ac700a..c59994cdb 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -1703,7 +1703,7 @@ }, "match_found": { "title": "Матч знайдено", - "description": "Підбір знайшов ваш матч і чекає на ваше підтвердження. Дзвонить навіть у тихі години." + "description": "Матчмейкінг знайшов ваш матч і чекає на ваше підтвердження. Дзвонить навіть у тихі години." }, "admin_call": { "title": "Виклики адміністраторів", diff --git a/public/sw-push.js b/public/sw-push.js index cda531c5a..0201ca1b8 100644 --- a/public/sw-push.js +++ b/public/sw-push.js @@ -71,8 +71,10 @@ const setAppBadge = async (unread) => { const RING_VIBRATION = [300, 100, 300, 100, 300]; -// A device clock running behind the server's reads `expiresAt` as further away -// than it is, so the wait is capped rather than trusted. +// Clock skew, both ways. A device clock behind the server's reads `expiresAt` +// as further away than it is, so the wait is capped; one ahead of it reads a +// ring that has only just arrived as already over, so it still gets a moment. +const MIN_RING_MS = 5_000; const MAX_RING_MS = 60_000; const expiryDelayOf = (expiresAt) => { @@ -86,29 +88,36 @@ const expiryDelayOf = (expiresAt) => { return null; } - return Math.min(Math.max(0, at - Date.now()), MAX_RING_MS); + return Math.min(Math.max(MIN_RING_MS, at - Date.now()), MAX_RING_MS); }; +// Deliberately not part of the push event's waitUntil. Chrome judges a push by +// whether its notification is still on screen when the event settles; one that +// has already been closed counts as a silent push, and once that budget is +// spent Chrome shows "This site has been updated in the background" instead. +// An idle worker lives on for about 30s, so the close usually still lands, and +// when it does not the ring simply stays until it is dismissed. +// // Matched on `expiresAt` as well as the tag: a newer push with the same tag has // already replaced this one on screen, and it has its own expiry to keep. -const closeAtExpiry = async (payload) => { +const closeAtExpiry = (payload) => { const delay = expiryDelayOf(payload.expiresAt); if (delay === null) { return; } - await new Promise((resolve) => setTimeout(resolve, delay)); - - const notifications = await self.registration.getNotifications( - payload.tag ? { tag: payload.tag } : {}, - ); + setTimeout(async () => { + const notifications = await self.registration.getNotifications( + payload.tag ? { tag: payload.tag } : {}, + ); - for (const notification of notifications) { - if (notification.data?.expiresAt === payload.expiresAt) { - notification.close(); + for (const notification of notifications) { + if (notification.data?.expiresAt === payload.expiresAt) { + notification.close(); + } } - } + }, delay); }; self.addEventListener("push", (event) => { diff --git a/tests/components/MatchmakingConfirm.spec.ts b/tests/components/MatchmakingConfirm.spec.ts index 3e10e8244..f7e495b4a 100644 --- a/tests/components/MatchmakingConfirm.spec.ts +++ b/tests/components/MatchmakingConfirm.spec.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { flushPromises } from "@vue/test-utils"; import { mountSuspended } from "@nuxt/test-utils/runtime"; +import { useNuxtApp } from "#imports"; import MatchmakingConfirm from "~/components/matchmaking/MatchmakingConfirm.vue"; import { useMatchmakingStore } from "~/stores/MatchmakingStore"; import { fakeServiceWorker } from "../helpers/fakeServiceWorker"; @@ -65,11 +66,22 @@ describe("MatchmakingConfirm ring", () => { await flushPromises(); expect(worker.closed()).toEqual(["MatchFound:c-1"]); + }); + + it("closes a ring that lands after the player has already accepted", async () => { + setConfirmation(confirmation()); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); - setConfirmation(confirmation({ confirmed: 6, isReady: true })); + setConfirmation(confirmation({ confirmed: 4, isReady: true })); await flushPromises(); - expect(worker.registration.getNotifications).toHaveBeenCalledTimes(1); + const late = worker.show("MatchFound:c-1"); + + setConfirmation(confirmation({ confirmed: 5, isReady: true })); + await flushPromises(); + + expect(late.close).toHaveBeenCalled(); }); it("closes the ring when the ready check ends without the player", async () => { @@ -83,6 +95,35 @@ describe("MatchmakingConfirm ring", () => { expect(worker.closed()).toEqual(["MatchFound:c-1"]); }); + it("closes the old ring when a new ready check replaces it", async () => { + setConfirmation(confirmation()); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); + + setConfirmation(confirmation({ confirmationId: "c-2" })); + await flushPromises(); + + expect(worker.closed()).toEqual(["MatchFound:c-1"]); + }); + + it("closes the ring once the match is created", async () => { + const push = vi + .spyOn(useNuxtApp().$router, "push") + .mockResolvedValue(undefined); + + setConfirmation(confirmation()); + wrapper = await mountSuspended(MatchmakingConfirm); + await flushPromises(); + + setConfirmation(confirmation({ confirmed: 10, matchId: "m-1" })); + await flushPromises(); + + expect(worker.closed()).toEqual(["MatchFound:c-1"]); + expect(push).toHaveBeenCalledWith("/matches/m-1"); + + push.mockRestore(); + }); + it("closes the ring on mount when the player has already accepted", async () => { setConfirmation(confirmation({ confirmationId: "c-2", isReady: true })); wrapper = await mountSuspended(MatchmakingConfirm); diff --git a/tests/composables/useCameraTalkback.spec.ts b/tests/composables/useCameraTalkback.spec.ts new file mode 100644 index 000000000..454c09070 --- /dev/null +++ b/tests/composables/useCameraTalkback.spec.ts @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope } from "vue"; +import { flushPromises } from "@vue/test-utils"; +import socket from "~/web-sockets/Socket"; +import { useCameraTalkback } from "~/composables/useCameraTalkback"; +import { hangupPlayerTalk } from "~/composables/useCameraApi"; +import { fakeServiceWorker } from "../helpers/fakeServiceWorker"; + +vi.mock("~/composables/useCameraApi", () => ({ + cameraPlayerTalkUrl: (matchId: string) => `talk/${matchId}`, + fetchCameraTalkStatus: vi.fn(async () => ({ ready: true })), + hangupPlayerTalk: vi.fn(async () => {}), + negotiateWebRtc: vi.fn(async () => {}), +})); + +vi.mock("~/composables/useIceServers", () => ({ + useIceServers: () => ({ load: async () => [] }), +})); + +class FakePeerConnection { + ontrack: unknown = null; + addTransceiver() {} + close() {} +} + +let worker: ReturnType; +let setPresence: ReturnType; + +function mountTalkback() { + const scope = effectScope(); + const talkback = scope.run(() => useCameraTalkback(() => "m-1"))!; + + return { scope, talkback }; +} + +const lastFocus = () => setPresence.mock.calls.at(-1)?.[0]?.focus; + +beforeEach(() => { + vi.stubGlobal("RTCPeerConnection", FakePeerConnection); + setPresence = vi.spyOn(socket, "setPresence").mockImplementation(() => {}); + worker = fakeServiceWorker({ tags: ["AdminCall:m-1", "MatchFound:c-1"] }); + vi.mocked(hangupPlayerTalk).mockClear(); +}); + +afterEach(() => { + worker.restore(); + setPresence.mockRestore(); + vi.unstubAllGlobals(); +}); + +describe("useCameraTalkback", () => { + it("drops whatever focus the previous page left behind", () => { + const { scope } = mountTalkback(); + + expect(setPresence).toHaveBeenCalledWith({ visible: true, focus: null }); + + scope.stop(); + }); + + it("closes the ring and reports the call as focused once it plays", async () => { + const { scope, talkback } = mountTalkback(); + + talkback.start(); + await flushPromises(); + + expect(talkback.talking.value).toBe(true); + expect(worker.closed()).toEqual(["AdminCall:m-1"]); + expect(lastFocus()).toBe("AdminCall:m-1"); + + talkback.end(); + await flushPromises(); + + expect(lastFocus()).toBeNull(); + + scope.stop(); + }); + + it("hangs up on leaving only if this tab was listening for the call", async () => { + const opened = mountTalkback(); + opened.scope.stop(); + + expect(hangupPlayerTalk).not.toHaveBeenCalled(); + + const connected = mountTalkback(); + connected.talkback.start(); + await flushPromises(); + connected.scope.stop(); + + expect(hangupPlayerTalk).toHaveBeenCalledWith("m-1"); + }); +}); diff --git a/tests/helpers/fakeServiceWorker.ts b/tests/helpers/fakeServiceWorker.ts index 307bcdcd4..3816a504e 100644 --- a/tests/helpers/fakeServiceWorker.ts +++ b/tests/helpers/fakeServiceWorker.ts @@ -35,6 +35,11 @@ export function fakeServiceWorker({ return { notifications, registration, + show(tag: string) { + const notification = { tag, close: vi.fn() }; + notifications.push(notification); + return notification; + }, serviceWorker, closed: () => notifications diff --git a/tests/public/swPush.spec.ts b/tests/public/swPush.spec.ts index 6172ceec2..65aba2636 100644 --- a/tests/public/swPush.spec.ts +++ b/tests/public/swPush.spec.ts @@ -59,6 +59,7 @@ function loadWorker() { const push = (payload: Record) => { let settled = false; + let visibleWhenSettled: number | undefined; let done: Promise = Promise.resolve(); listeners.push({ @@ -66,11 +67,16 @@ function loadWorker() { waitUntil: (promise: Promise) => { done = promise.then(() => { settled = true; + visibleWhenSettled = onScreen.length; }); }, }); - return { done, isSettled: () => settled }; + return { + done, + isSettled: () => settled, + visibleWhenSettled: () => visibleWhenSettled, + }; }; return { @@ -150,17 +156,15 @@ describe("sw-push", () => { expect(options).toMatchObject({ tag: "chat:lobby:abc", renotify: true }); }); - it("closes the notification at expiresAt and holds the event open until then", async () => { + it("closes the notification at expiresAt", async () => { const worker = loadWorker(); - const event = worker.push(matchFound()); + worker.push(matchFound()); await vi.advanceTimersByTimeAsync(29_999); expect(worker.onScreen()).toHaveLength(1); - expect(event.isSettled()).toBe(false); await vi.advanceTimersByTimeAsync(1); - await event.done; expect(worker.onScreen()).toHaveLength(0); expect(worker.registration.getNotifications).toHaveBeenCalledWith({ @@ -168,6 +172,17 @@ describe("sw-push", () => { }); }); + it("settles the push event while the ring is still on screen, or Chrome counts it as silent", async () => { + const worker = loadWorker(); + + const event = worker.push(matchFound()); + await vi.advanceTimersByTimeAsync(0); + + expect(event.isSettled()).toBe(true); + expect(event.visibleWhenSettled()).toBe(1); + expect(vi.getTimerCount()).toBe(1); + }); + it("sets no timer without expiresAt", async () => { const worker = loadWorker(); @@ -193,12 +208,20 @@ describe("sw-push", () => { expect(worker.onScreen()).toHaveLength(0); }); - it("closes straight away when expiresAt has already passed", async () => { + it("still shows a ring for a moment when the device clock runs ahead", async () => { const worker = loadWorker(); const event = worker.push(matchFound({ expiresAt: inSeconds(-5) })); await vi.advanceTimersByTimeAsync(0); - await event.done; + + expect(event.isSettled()).toBe(true); + expect(event.visibleWhenSettled()).toBe(1); + + await vi.advanceTimersByTimeAsync(4_999); + + expect(worker.onScreen()).toHaveLength(1); + + await vi.advanceTimersByTimeAsync(1); expect(worker.onScreen()).toHaveLength(0); });