From af188b7f1ee77645392047b34051ca3c8616f11f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:41:17 -0400 Subject: [PATCH 1/5] feature: flash the browser tab for match found, calls, chat and notifications While the 5stack tab is hidden, a match found, an organizer calling on the camera page, a new chat message or a new bell notification flashes the title as "(N) " and swaps the favicon for the branded icon with a red count dot. Looking at the tab again stops it and restores both. - useTabFlash: per-kind counts, keyed dedupe, unhead-safe title handling - tab-flash plugin: match confirmation + bell (only what arrives after the tab was hidden), skipped in the chat pop-out - chat hooks in useChatTabSetup / useIncomingDirectMessages, call hook in useCameraTalkback - useBranding: faviconOverride + brandedFaviconHref so a flash and a branding change can't clobber each other - per-device toggles under Settings > Notifications > Browser tab --- composables/useBranding.ts | 61 ++- composables/useCameraTalkback.ts | 3 + composables/useChatTabSetup.ts | 10 +- composables/useIncomingDirectMessages.ts | 17 +- composables/useTabFlash.ts | 335 ++++++++++++++ composables/useTabFlashSettings.ts | 56 +++ i18n/locales/ar_SA.json | 26 ++ i18n/locales/da_DK.json | 26 ++ i18n/locales/de_DE.json | 26 ++ i18n/locales/en.json | 26 ++ i18n/locales/es_ES.json | 26 ++ i18n/locales/fr_FR.json | 26 ++ i18n/locales/it_IT.json | 26 ++ i18n/locales/ja_JP.json | 26 ++ i18n/locales/ko_KR.json | 26 ++ i18n/locales/pl_PL.json | 26 ++ i18n/locales/pt_BR.json | 26 ++ i18n/locales/ru_RU.json | 26 ++ i18n/locales/sv_SE.json | 26 ++ i18n/locales/tr_TR.json | 26 ++ i18n/locales/uk_UA.json | 26 ++ i18n/locales/zh_Hans.json | 26 ++ i18n/locales/zh_Hant.json | 26 ++ pages/settings/notification-preferences.vue | 76 ++++ plugins/tab-flash.client.ts | 98 +++++ stores/NotificationStore.ts | 7 + tests/composables/useTabFlash.spec.ts | 407 ++++++++++++++++++ .../settings/notification-preferences.spec.ts | 69 +++ 28 files changed, 1563 insertions(+), 18 deletions(-) create mode 100644 composables/useTabFlash.ts create mode 100644 composables/useTabFlashSettings.ts create mode 100644 plugins/tab-flash.client.ts create mode 100644 tests/composables/useTabFlash.spec.ts diff --git a/composables/useBranding.ts b/composables/useBranding.ts index 690eb51b0..ab7d2c3fb 100644 --- a/composables/useBranding.ts +++ b/composables/useBranding.ts @@ -1,6 +1,28 @@ -import { computed, watch } from "vue"; +import { computed, ref, watch } from "vue"; import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; +// The browser falls back to this on its own when the page has no icon link. +const DEFAULT_FAVICON = "/favicon.ico"; + +// Something that has to own the tab's icon for a while (the tab flash's alert +// icon) sets this instead of writing the link itself, so a branding change in +// the meantime can't overwrite it and clearing it always lands on the current +// branded icon rather than a stale copy. +export const faviconOverride = ref(null); + +export const brandedFaviconHref = computed(() => { + const setting = useApplicationSettingsStore().settings.find( + (s: { name: string }) => s.name === "public.favicon_url", + ); + + if (!setting?.value) { + return DEFAULT_FAVICON; + } + + // The asset path is stable, so the setting value is the cache-busting token. + return `https://${useRuntimeConfig().public.apiDomain}/branding/favicon?v=${encodeURIComponent(setting.value)}`; +}); + // The app is dark-only, so these are the only branding colors applied. const colorMap: Record = { // Core @@ -103,6 +125,28 @@ export function useBranding() { // Swap the favicon + iOS apple-touch-icon to the uploaded branding asset. // The PWA manifest link is set once in app.vue (host-aware), not here, so // Chrome never sees the static "5stack" manifest flip to the branded one. + watch( + [faviconOverride, brandedFaviconHref], + ([override, branded]) => { + const href = override ?? branded; + let link = document.querySelector( + "link[rel='icon']", + ) as HTMLLinkElement | null; + + if (!link) { + if (href === DEFAULT_FAVICON) { + return; + } + link = document.createElement("link"); + link.rel = "icon"; + document.head.appendChild(link); + } + + link.href = href; + }, + { immediate: true }, + ); + watch( () => store.settings, () => { @@ -117,27 +161,14 @@ export function useBranding() { // Asset paths are stable, so the setting values are used as cache-busting // version tokens. Favicon and logo/PWA icon are independent uploads now. - const faviconVersion = encodeURIComponent(faviconSetting?.value || ""); const pwaVersion = encodeURIComponent(pwaIconSetting?.value || ""); - if (faviconSetting?.value) { - let link = document.querySelector( - "link[rel='icon']", - ) as HTMLLinkElement | null; - if (!link) { - link = document.createElement("link"); - link.rel = "icon"; - document.head.appendChild(link); - } - link.href = `https://${apiDomain}/branding/favicon?v=${faviconVersion}`; - } - // iOS "Add to Home Screen" uses apple-touch-icon, not the manifest icon. // Prefer the 512px PWA icon (a tiny .ico favicon makes a poor home icon). const appleHref = pwaIconSetting?.value ? `https://${apiDomain}/branding/pwa/512?v=${pwaVersion}` : faviconSetting?.value - ? `https://${apiDomain}/branding/favicon?v=${faviconVersion}` + ? brandedFaviconHref.value : null; if (appleHref) { diff --git a/composables/useCameraTalkback.ts b/composables/useCameraTalkback.ts index b8f79640a..4aaae4ee6 100644 --- a/composables/useCameraTalkback.ts +++ b/composables/useCameraTalkback.ts @@ -6,6 +6,7 @@ import { negotiateWebRtc, } from "~/composables/useCameraApi"; import { useIceServers } from "~/composables/useIceServers"; +import { useTabFlash } from "~/composables/useTabFlash"; // 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 @@ -68,6 +69,7 @@ export function useCameraTalkback(matchId: () => string) { await negotiateWebRtc(pc, cameraPlayerTalkUrl(matchId()), "include"); talking.value = true; + useTabFlash().signal("admin_call"); } catch { end(); } @@ -75,6 +77,7 @@ export function useCameraTalkback(matchId: () => string) { function end() { talking.value = false; + useTabFlash().clear("admin_call"); muted.value = false; talkPc?.close(); talkPc = null; diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 924c21cf9..178e2664a 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -2,10 +2,11 @@ import { watch, computed } from "vue"; import { useI18n } from "vue-i18n"; import { useChatTabs } from "~/composables/useChatTabs"; import { useChatReadState } from "~/composables/useChatReadState"; +import { useTabFlash } from "~/composables/useTabFlash"; import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; import { useAuthStore } from "~/stores/AuthStore"; import { e_player_roles_enum } from "~/generated/zeus"; -import socket, { type Lobby } from "~/web-sockets/Socket"; +import socket, { chatMessageKey, type Lobby } from "~/web-sockets/Socket"; export function useChatTabSetup() { const { t } = useI18n(); @@ -41,6 +42,13 @@ export function useChatTabSetup() { return; } + // Ahead of the on-screen check, which counts an open room as read even + // while the browser tab is hidden. Match rooms relay in-game chat line + // by line, the same reason their push is off by default. + if (tab.type !== "match") { + useTabFlash().signal("chat", chatMessageKey(message)); + } + const isOnScreen = activeTabId.value === tab.id && rightSidebarOpen.value && diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index c1a14ab18..908e11952 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -1,5 +1,9 @@ import { watch } from "vue"; -import socket from "~/web-sockets/Socket"; +import socket, { + chatMessageKey, + type LobbyMessage, +} from "~/web-sockets/Socket"; +import { useTabFlash } from "~/composables/useTabFlash"; import { directTabId, peerSteamId, @@ -94,13 +98,22 @@ export function useIncomingDirectMessages() { socket.listen( "direct:incoming", - (data: { roomId: string; from: DirectMessagePeer }) => { + (data: { + roomId: string; + from: DirectMessagePeer; + message?: LobbyMessage; + }) => { const steamId = authStore.me?.steam_id; if (!steamId || !data?.roomId) { return; } + // The room's own `lobby:chat` carries the same id when its tab is open. + if (data.message) { + useTabFlash().signal("chat", chatMessageKey(data.message)); + } + // Deliberately does not inject the message: opening the tab makes // useChatTabSetup join the room, and the join's history snapshot delivers // it (deduped by chatMessageKey either way). diff --git a/composables/useTabFlash.ts b/composables/useTabFlash.ts new file mode 100644 index 000000000..fb2a0504e --- /dev/null +++ b/composables/useTabFlash.ts @@ -0,0 +1,335 @@ +import { reactive, readonly } from "vue"; +import { brandedFaviconHref, faviconOverride } from "~/composables/useBranding"; +import { + isTabFlashEnabled, + type TabFlashKind, +} from "~/composables/useTabFlashSettings"; + +// Most urgent first: the title names the top kind that has anything waiting. +const PRIORITY: TabFlashKind[] = ["admin_call", "match_found", "chat", "bell"]; + +const BLINK_MS = 1000; +const SEEN_KEY_LIMIT = 500; +const ICON_SIZE = 64; +const ICON_LOAD_TIMEOUT_MS = 5000; +const DEFAULT_FAVICON = "/favicon.ico"; +const FALLBACK_ICON = "/favicon/64.png"; +const COUNT_DOT_COLOR = "#ef4444"; + +const counts = reactive>({ + admin_call: 0, + match_found: 0, + chat: 0, + bell: 0, +}); + +const seenKeys = new Set(); +const alertIcons = new Map>(); + +let installed = false; +let blinkTimer: ReturnType | null = null; +let baseTitle: string | null = null; +let alertTitle = ""; +let showingAlert = false; +let iconRequest = 0; + +function isHidden() { + return document.visibilityState === "hidden"; +} + +function prefersReducedMotion() { + try { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; + } catch { + return false; + } +} + +function total() { + return PRIORITY.reduce((sum, kind) => sum + counts[kind], 0); +} + +function alertText(kind: TabFlashKind, count: number): string { + const i18n = useNuxtApp().$i18n; + + switch (kind) { + case "admin_call": + return i18n.t("tab_flash.admin_call", count); + case "match_found": + return i18n.t("tab_flash.match_found", count); + case "chat": + return i18n.t("tab_flash.chat", count); + case "bell": + return i18n.t("tab_flash.bell", count); + } +} + +// unhead owns document.title and rewrites it whenever the page's head changes, +// flash or not. Anything that isn't one of our two strings is its newer title, +// and is what the flash alternates with and hands back when it stops. +function adoptExternalTitle() { + const current = document.title; + + if (current !== alertTitle && current !== baseTitle) { + baseTitle = current; + } +} + +function blink() { + adoptExternalTitle(); + showingAlert = prefersReducedMotion() ? true : !showingAlert; + document.title = showingAlert ? alertTitle : (baseTitle ?? ""); +} + +function stop() { + if (blinkTimer) { + clearInterval(blinkTimer); + blinkTimer = null; + } + + if (baseTitle !== null) { + adoptExternalTitle(); + document.title = baseTitle; + baseTitle = null; + } + + alertTitle = ""; + showingAlert = false; + iconRequest += 1; + faviconOverride.value = null; +} + +function refresh() { + const count = total(); + const top = PRIORITY.find((kind) => counts[kind] > 0); + + if (!top || !isHidden()) { + stop(); + return; + } + + if (baseTitle === null) { + baseTitle = document.title; + } else { + adoptExternalTitle(); + } + + // Read back, document.title comes with its whitespace collapsed, and the + // alert has to compare equal to it or it would be adopted as the page's own. + alertTitle = `(${count}) ${alertText(top, count)}` + .replace(/\s+/g, " ") + .trim(); + + if (!blinkTimer) { + showingAlert = true; + blinkTimer = setInterval(blink, BLINK_MS); + } + + if (showingAlert) { + document.title = alertTitle; + } + + void showAlertIcon(count > 9 ? "9+" : String(count)); +} + +// Hidden tabs throttle timers to as little as once a minute, so the icon holds +// its alert state for the whole flash instead of blinking with the title. +async function showAlertIcon(label: string) { + const request = ++iconRequest; + const icon = await alertIcon(brandedFaviconHref.value, label); + + if (request !== iconRequest || !icon) { + return; + } + + faviconOverride.value = icon; +} + +function alertIcon(href: string, label: string) { + const cacheKey = `${href}|${label}`; + let icon = alertIcons.get(cacheKey); + + if (!icon) { + icon = drawAlertIcon(href, label); + alertIcons.set(cacheKey, icon); + } + + return icon; +} + +async function drawAlertIcon( + href: string, + label: string, +): Promise { + const canvas = document.createElement("canvas"); + canvas.width = ICON_SIZE; + canvas.height = ICON_SIZE; + + let context: CanvasRenderingContext2D | null = null; + try { + context = canvas.getContext("2d"); + } catch { + context = null; + } + + if (!context) { + return null; + } + + // The branded icon lives on the API's origin. The tab already fetched it + // without CORS, and a cached copy of that response would taint the canvas, + // so the CORS request goes to a URL of its own. + const sources = + href === DEFAULT_FAVICON + ? [FALLBACK_ICON] + : [`${href}${href.includes("?") ? "&" : "?"}tab-flash=1`, FALLBACK_ICON]; + + for (const source of sources) { + try { + const image = await loadImage(source); + context.clearRect(0, 0, ICON_SIZE, ICON_SIZE); + context.drawImage(image, 0, 0, ICON_SIZE, ICON_SIZE); + drawCountDot(context, label); + return canvas.toDataURL("image/png"); + } catch { + // A failed load or a tainted canvas moves on to the next source. + } + } + + return null; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + const timeout = setTimeout( + () => reject(new Error("icon load timed out")), + ICON_LOAD_TIMEOUT_MS, + ); + + image.crossOrigin = "anonymous"; + image.onload = () => { + clearTimeout(timeout); + resolve(image); + }; + image.onerror = () => { + clearTimeout(timeout); + reject(new Error("icon failed to load")); + }; + image.src = src; + }); +} + +function drawCountDot(context: CanvasRenderingContext2D, label: string) { + const radius = 20; + const fontSize = label.length > 1 ? 26 : 32; + + context.font = `700 ${fontSize}px Oxanium, system-ui, sans-serif`; + + const width = Math.max(radius * 2, context.measureText(label).width + 14); + const left = ICON_SIZE - width; + + context.beginPath(); + context.arc(left + radius, radius, radius, Math.PI / 2, (Math.PI * 3) / 2); + context.arc( + ICON_SIZE - radius, + radius, + radius, + (Math.PI * 3) / 2, + Math.PI / 2, + ); + context.closePath(); + context.fillStyle = COUNT_DOT_COLOR; + context.fill(); + + context.fillStyle = "#ffffff"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(label, left + width / 2, radius + 1); +} + +function remember(key: string) { + if (seenKeys.size >= SEEN_KEY_LIMIT) { + const oldest = seenKeys.values().next().value; + if (oldest !== undefined) { + seenKeys.delete(oldest); + } + } + + seenKeys.add(key); +} + +// A key is spent the first time it is seen, even while the tab is visible: +// something the player already saw must not flash once they look away. +function signal(kind: TabFlashKind, key?: string) { + if (!installed) { + return; + } + + if (key !== undefined) { + const seenKey = `${kind}:${key}`; + + if (seenKeys.has(seenKey)) { + return; + } + + remember(seenKey); + } + + if (!isHidden() || !isTabFlashEnabled(kind)) { + return; + } + + counts[kind] += 1; + safely(refresh); +} + +function clear(kind: TabFlashKind) { + if (counts[kind] === 0) { + return; + } + + counts[kind] = 0; + safely(refresh); +} + +// Signals come from inside chat and call handlers, which must not lose their +// own work to a title that failed to update. +function safely(update: () => void) { + try { + update(); + } catch (error) { + console.error("[tab-flash] failed to update the tab", error); + } +} + +function onVisibilityChange() { + if (isHidden()) { + return; + } + + for (const kind of PRIORITY) { + counts[kind] = 0; + } + + stop(); +} + +// Only the main window installs this. The chat pop-out is a document of its +// own, and flashing it too would double every alert. +export function installTabFlash() { + if (installed || typeof document === "undefined") { + return; + } + + installed = true; + document.addEventListener("visibilitychange", onVisibilityChange); +} + +export function useTabFlash() { + return { + counts: readonly(counts), + signal, + clear, + }; +} diff --git a/composables/useTabFlashSettings.ts b/composables/useTabFlashSettings.ts new file mode 100644 index 000000000..2cbc1be12 --- /dev/null +++ b/composables/useTabFlashSettings.ts @@ -0,0 +1,56 @@ +import { ref } from "vue"; + +export type TabFlashKind = "admin_call" | "match_found" | "chat" | "bell"; + +export const TAB_FLASH_KINDS: TabFlashKind[] = [ + "match_found", + "admin_call", + "chat", + "bell", +]; + +const STORAGE_KEYS: Record = { + match_found: "5stack:tab-flash:match-found", + admin_call: "5stack:tab-flash:admin-call", + chat: "5stack:tab-flash:chat", + bell: "5stack:tab-flash:bell", +}; + +// Where storage is blocked the choice still has to hold for this page's life. +const unsaved: Partial> = {}; + +// Read at signal time rather than cached, so a toggle in another 5stack tab on +// the same device applies here without a reload. +export function isTabFlashEnabled(kind: TabFlashKind): boolean { + try { + const stored = localStorage.getItem(STORAGE_KEYS[kind]); + if (stored !== null) { + return stored !== "false"; + } + } catch { + // Blocked storage falls through to the in-memory choice. + } + + return unsaved[kind] ?? true; +} + +export function useTabFlashSettings() { + const enabled = ref( + Object.fromEntries( + TAB_FLASH_KINDS.map((kind) => [kind, isTabFlashEnabled(kind)]), + ) as Record, + ); + + function setEnabled(kind: TabFlashKind, value: boolean) { + enabled.value = { ...enabled.value, [kind]: value }; + unsaved[kind] = value; + + try { + localStorage.setItem(STORAGE_KEYS[kind], String(value)); + } catch { + // Kept in memory above. + } + } + + return { enabled, setEnabled }; +} diff --git a/i18n/locales/ar_SA.json b/i18n/locales/ar_SA.json index 1e2d86079..0a0c57878 100644 --- a/i18n/locales/ar_SA.json +++ b/i18n/locales/ar_SA.json @@ -1829,6 +1829,26 @@ "to": "إلى", "reset": "إعادة الضبط", "incomplete": "حدّد وقت البداية والنهاية معًا لحفظ ساعات الهدوء." + }, + "tab_flash": { + "title": "علامة تبويب المتصفح", + "description": "يومض عنوان هذه العلامة وأيقونتها عندما يحتاجك شيء ما وهي في الخلفية. يُحفظ على هذا الجهاز.", + "match_found": { + "title": "تم العثور على مباراة", + "description": "مباراة جاهزة وتنتظر قبولك." + }, + "admin_call": { + "title": "مكالمات المنظّم", + "description": "يبدأ منظّم المباراة بالتحدث إليك عبر صفحة الكاميرا." + }, + "chat": { + "title": "رسائل الدردشة", + "description": "رسالة جديدة في محادثة أو ردهة أو غرفة بطولة. تُستثنى دردشة المباراة داخل اللعبة." + }, + "bell": { + "title": "الإشعارات", + "description": "وصول شيء جديد إلى جرس التنبيهات." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "تم العثور على مباراة", + "admin_call": "منظّم يتصل بك", + "chat": "رسالة جديدة | رسائل جديدة", + "bell": "إشعار جديد | إشعارات جديدة" + }, "roster_picker": { "add_starter": "أضف أساسيًا", "add_substitute": "أضف لاعبًا بديلاً", diff --git a/i18n/locales/da_DK.json b/i18n/locales/da_DK.json index 4d74d3c4c..2338f3b70 100644 --- a/i18n/locales/da_DK.json +++ b/i18n/locales/da_DK.json @@ -1829,6 +1829,26 @@ "to": "Til", "reset": "Nulstil", "incomplete": "Angiv både et start- og et sluttidspunkt for at gemme stilletimer." + }, + "tab_flash": { + "title": "Browserfane", + "description": "Få fanens titel og ikon til at blinke, når noget kræver din opmærksomhed, mens den er i baggrunden. Gemmes på denne enhed.", + "match_found": { + "title": "Kamp fundet", + "description": "En kamp er klar og venter på, at du accepterer." + }, + "admin_call": { + "title": "Arrangøropkald", + "description": "En kamparrangør begynder at tale til dig på din kameraside." + }, + "chat": { + "title": "Chatbeskeder", + "description": "En ny besked i en samtale, en lobby eller et turneringsrum. Chat fra selve kampen i spillet er udeladt." + }, + "bell": { + "title": "Notifikationer", + "description": "Der kommer noget nyt i din notifikationsklokke." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Kamp fundet", + "admin_call": "En arrangør ringer", + "chat": "Ny besked | Nye beskeder", + "bell": "Ny notifikation | Nye notifikationer" + }, "roster_picker": { "add_starter": "Tilføj starter", "add_substitute": "Tilføj reserve", diff --git a/i18n/locales/de_DE.json b/i18n/locales/de_DE.json index 8fb516d08..017d337d7 100644 --- a/i18n/locales/de_DE.json +++ b/i18n/locales/de_DE.json @@ -1829,6 +1829,26 @@ "to": "Bis", "reset": "Zurücksetzen", "incomplete": "Lege eine Start- und eine Endzeit fest, um Ruhezeiten zu speichern." + }, + "tab_flash": { + "title": "Browser-Tab", + "description": "Lässt Titel und Symbol dieses Tabs blinken, wenn etwas deine Aufmerksamkeit braucht, während er im Hintergrund ist. Wird auf diesem Gerät gespeichert.", + "match_found": { + "title": "Match gefunden", + "description": "Ein Match ist bereit und wartet darauf, dass du annimmst." + }, + "admin_call": { + "title": "Organisator-Anrufe", + "description": "Ein Match-Organisator beginnt, über deine Kameraseite mit dir zu sprechen." + }, + "chat": { + "title": "Chatnachrichten", + "description": "Eine neue Nachricht in einer Unterhaltung, Lobby oder einem Turnierraum. Der Ingame-Chat von Matches ist ausgenommen." + }, + "bell": { + "title": "Benachrichtigungen", + "description": "Etwas Neues landet in deiner Benachrichtigungsglocke." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Match gefunden", + "admin_call": "Ein Organisator ruft an", + "chat": "Neue Nachricht | Neue Nachrichten", + "bell": "Neue Benachrichtigung | Neue Benachrichtigungen" + }, "roster_picker": { "add_starter": "Starter hinzufügen", "add_substitute": "Ersatzspieler hinzufügen", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index cef42bc2c..814630f56 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -1829,6 +1829,26 @@ "to": "To", "reset": "Reset", "incomplete": "Set both a start and an end time to save quiet hours." + }, + "tab_flash": { + "title": "Browser tab", + "description": "Flash this tab's title and icon when something needs you while it's in the background. Saved on this device.", + "match_found": { + "title": "Match found", + "description": "A match is ready and waiting for you to accept." + }, + "admin_call": { + "title": "Organizer calls", + "description": "A match organizer starts talking to you on your camera page." + }, + "chat": { + "title": "Chat messages", + "description": "A new message in a conversation, lobby or tournament room. In-game match chat is left out." + }, + "bell": { + "title": "Notifications", + "description": "Something new lands in your alert bell." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Match found", + "admin_call": "An organizer is calling", + "chat": "New message | New messages", + "bell": "New notification | New notifications" + }, "roster_picker": { "add_starter": "Add starter", "add_substitute": "Add substitute", diff --git a/i18n/locales/es_ES.json b/i18n/locales/es_ES.json index af85d643e..25f0d4540 100644 --- a/i18n/locales/es_ES.json +++ b/i18n/locales/es_ES.json @@ -1829,6 +1829,26 @@ "to": "Hasta", "reset": "Restablecer", "incomplete": "Define una hora de inicio y otra de fin para guardar las horas de silencio." + }, + "tab_flash": { + "title": "Pestaña del navegador", + "description": "Hace parpadear el título y el icono de esta pestaña cuando algo te necesita mientras está en segundo plano. Se guarda en este dispositivo.", + "match_found": { + "title": "Partida encontrada", + "description": "Hay una partida lista esperando a que la aceptes." + }, + "admin_call": { + "title": "Llamadas del organizador", + "description": "Un organizador de la partida empieza a hablarte desde tu página de cámara." + }, + "chat": { + "title": "Mensajes de chat", + "description": "Un mensaje nuevo en una conversación, un lobby o una sala de torneo. El chat de la partida dentro del juego queda fuera." + }, + "bell": { + "title": "Notificaciones", + "description": "Llega algo nuevo a tu campana de avisos." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Partida encontrada", + "admin_call": "Un organizador te está llamando", + "chat": "Mensaje nuevo | Mensajes nuevos", + "bell": "Notificación nueva | Notificaciones nuevas" + }, "roster_picker": { "add_starter": "Añadir titular", "add_substitute": "Añadir suplente", diff --git a/i18n/locales/fr_FR.json b/i18n/locales/fr_FR.json index 333a92a08..da0510a2f 100644 --- a/i18n/locales/fr_FR.json +++ b/i18n/locales/fr_FR.json @@ -1829,6 +1829,26 @@ "to": "À", "reset": "Réinitialiser", "incomplete": "Définissez une heure de début et une heure de fin pour enregistrer les heures silencieuses." + }, + "tab_flash": { + "title": "Onglet du navigateur", + "description": "Fait clignoter le titre et l'icône de cet onglet quand quelque chose vous attend alors qu'il est en arrière-plan. Enregistré sur cet appareil.", + "match_found": { + "title": "Match trouvé", + "description": "Un match est prêt et attend que vous l'acceptiez." + }, + "admin_call": { + "title": "Appels de l'organisateur", + "description": "Un organisateur du match commence à vous parler depuis votre page caméra." + }, + "chat": { + "title": "Messages de chat", + "description": "Un nouveau message dans une conversation, un lobby ou un salon de tournoi. Le chat en jeu des matchs est exclu." + }, + "bell": { + "title": "Notifications", + "description": "Quelque chose de nouveau arrive dans votre cloche d'alertes." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Match trouvé", + "admin_call": "Un organisateur vous appelle", + "chat": "Nouveau message | Nouveaux messages", + "bell": "Nouvelle notification | Nouvelles notifications" + }, "roster_picker": { "add_starter": "Ajouter un titulaire", "add_substitute": "Ajouter un remplaçant", diff --git a/i18n/locales/it_IT.json b/i18n/locales/it_IT.json index 00ef4b2ec..5683d4514 100644 --- a/i18n/locales/it_IT.json +++ b/i18n/locales/it_IT.json @@ -1829,6 +1829,26 @@ "to": "A", "reset": "Reimposta", "incomplete": "Imposta sia un orario di inizio sia uno di fine per salvare le ore di silenzio." + }, + "tab_flash": { + "title": "Scheda del browser", + "description": "Fa lampeggiare il titolo e l'icona di questa scheda quando qualcosa richiede la tua attenzione mentre è in background. Salvato su questo dispositivo.", + "match_found": { + "title": "Match trovato", + "description": "Una partita è pronta e aspetta che tu la accetti." + }, + "admin_call": { + "title": "Chiamate dell'organizzatore", + "description": "Un organizzatore della partita inizia a parlarti dalla tua pagina della fotocamera." + }, + "chat": { + "title": "Messaggi di chat", + "description": "Un nuovo messaggio in una conversazione, una lobby o una sala torneo. La chat di gioco delle partite è esclusa." + }, + "bell": { + "title": "Notifiche", + "description": "Arriva qualcosa di nuovo nella tua campanella degli avvisi." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Match trovato", + "admin_call": "Un organizzatore ti sta chiamando", + "chat": "Nuovo messaggio | Nuovi messaggi", + "bell": "Nuova notifica | Nuove notifiche" + }, "roster_picker": { "add_starter": "Aggiungi titolare", "add_substitute": "Aggiungi riserva", diff --git a/i18n/locales/ja_JP.json b/i18n/locales/ja_JP.json index b91063c3a..587547ba2 100644 --- a/i18n/locales/ja_JP.json +++ b/i18n/locales/ja_JP.json @@ -1829,6 +1829,26 @@ "to": "終了", "reset": "リセット", "incomplete": "サイレント時間を保存するには、開始時刻と終了時刻の両方を設定してください。" + }, + "tab_flash": { + "title": "ブラウザタブ", + "description": "このタブがバックグラウンドにあるときに対応が必要なことが起きると、タブのタイトルとアイコンを点滅させます。この端末に保存されます。", + "match_found": { + "title": "マッチが見つかりました", + "description": "マッチの準備ができ、あなたの承認を待っています。" + }, + "admin_call": { + "title": "主催者からの通話", + "description": "試合の主催者がカメラページであなたに話しかけ始めたとき。" + }, + "chat": { + "title": "チャットメッセージ", + "description": "会話、ロビー、トーナメントルームの新着メッセージ。試合中のゲーム内チャットは対象外です。" + }, + "bell": { + "title": "通知", + "description": "通知ベルに新しいものが届いたとき。" + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "マッチが見つかりました", + "admin_call": "主催者から通話が来ています", + "chat": "新着メッセージ | 新着メッセージ", + "bell": "新しい通知 | 新しい通知" + }, "roster_picker": { "add_starter": "スタメンを追加", "add_substitute": "控えを追加", diff --git a/i18n/locales/ko_KR.json b/i18n/locales/ko_KR.json index 2d41ab6c0..f9bf4bb4c 100644 --- a/i18n/locales/ko_KR.json +++ b/i18n/locales/ko_KR.json @@ -1829,6 +1829,26 @@ "to": "종료", "reset": "초기화", "incomplete": "방해 금지 시간을 저장하려면 시작 시각과 종료 시각을 모두 설정하세요." + }, + "tab_flash": { + "title": "브라우저 탭", + "description": "이 탭이 백그라운드에 있을 때 확인이 필요한 일이 생기면 탭 제목과 아이콘을 깜박입니다. 이 기기에 저장됩니다.", + "match_found": { + "title": "매치 찾음", + "description": "매치가 준비되어 수락을 기다리고 있습니다." + }, + "admin_call": { + "title": "주최자 통화", + "description": "경기 주최자가 카메라 페이지에서 말을 걸기 시작할 때." + }, + "chat": { + "title": "채팅 메시지", + "description": "대화, 로비 또는 토너먼트 방의 새 메시지. 경기 중 게임 내 채팅은 제외됩니다." + }, + "bell": { + "title": "알림", + "description": "알림 종에 새 항목이 도착할 때." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "매치 찾음", + "admin_call": "주최자가 통화를 요청했습니다", + "chat": "새 메시지 | 새 메시지", + "bell": "새 알림 | 새 알림" + }, "roster_picker": { "add_starter": "주전 추가", "add_substitute": "교체 선수 추가", diff --git a/i18n/locales/pl_PL.json b/i18n/locales/pl_PL.json index c3931b1ca..312a17ccd 100644 --- a/i18n/locales/pl_PL.json +++ b/i18n/locales/pl_PL.json @@ -1829,6 +1829,26 @@ "to": "Do", "reset": "Resetuj", "incomplete": "Ustaw godzinę początku i końca, aby zapisać godziny ciszy." + }, + "tab_flash": { + "title": "Karta przeglądarki", + "description": "Miga tytułem i ikoną tej karty, gdy coś wymaga twojej uwagi, a karta jest w tle. Zapisywane na tym urządzeniu.", + "match_found": { + "title": "Znaleziono mecz", + "description": "Mecz jest gotowy i czeka, aż go zaakceptujesz." + }, + "admin_call": { + "title": "Rozmowy z organizatorem", + "description": "Organizator meczu zaczyna mówić do ciebie przez stronę kamery." + }, + "chat": { + "title": "Wiadomości czatu", + "description": "Nowa wiadomość w rozmowie, lobby lub pokoju turniejowym. Czat z gry podczas meczu jest pomijany." + }, + "bell": { + "title": "Powiadomienia", + "description": "W dzwonku powiadomień pojawia się coś nowego." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Znaleziono mecz", + "admin_call": "Organizator do ciebie dzwoni", + "chat": "Nowa wiadomość | Nowe wiadomości", + "bell": "Nowe powiadomienie | Nowe powiadomienia" + }, "roster_picker": { "add_starter": "Dodaj podstawowego gracza", "add_substitute": "Dodaj rezerwowego", diff --git a/i18n/locales/pt_BR.json b/i18n/locales/pt_BR.json index 55e179c90..357eee673 100644 --- a/i18n/locales/pt_BR.json +++ b/i18n/locales/pt_BR.json @@ -1829,6 +1829,26 @@ "to": "Até", "reset": "Redefinir", "incomplete": "Defina tanto um horário de início quanto de término para salvar o horário silencioso." + }, + "tab_flash": { + "title": "Aba do navegador", + "description": "Faz o título e o ícone desta aba piscarem quando algo precisa de você enquanto ela está em segundo plano. Salvo neste dispositivo.", + "match_found": { + "title": "Partida encontrada", + "description": "Uma partida está pronta e esperando você aceitar." + }, + "admin_call": { + "title": "Chamadas do organizador", + "description": "Um organizador da partida começa a falar com você pela sua página da câmera." + }, + "chat": { + "title": "Mensagens de chat", + "description": "Uma nova mensagem em uma conversa, lobby ou sala de torneio. O chat da partida dentro do jogo fica de fora." + }, + "bell": { + "title": "Notificações", + "description": "Algo novo chega ao seu sino de alertas." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Partida encontrada", + "admin_call": "Um organizador está chamando você", + "chat": "Nova mensagem | Novas mensagens", + "bell": "Nova notificação | Novas notificações" + }, "roster_picker": { "add_starter": "Adicionar titular", "add_substitute": "Adicionar reserva", diff --git a/i18n/locales/ru_RU.json b/i18n/locales/ru_RU.json index 357336756..b2bd230d3 100644 --- a/i18n/locales/ru_RU.json +++ b/i18n/locales/ru_RU.json @@ -1829,6 +1829,26 @@ "to": "До", "reset": "Сбросить", "incomplete": "Задайте время начала и окончания, чтобы сохранить тихие часы." + }, + "tab_flash": { + "title": "Вкладка браузера", + "description": "Заголовок и значок этой вкладки мигают, когда вы нужны, а вкладка в фоне. Сохраняется на этом устройстве.", + "match_found": { + "title": "Матч найден", + "description": "Матч готов и ждёт, пока вы его примете." + }, + "admin_call": { + "title": "Звонки организатора", + "description": "Организатор матча начинает говорить с вами через страницу камеры." + }, + "chat": { + "title": "Сообщения чата", + "description": "Новое сообщение в переписке, лобби или турнирной комнате. Внутриигровой чат матча не учитывается." + }, + "bell": { + "title": "Уведомления", + "description": "В колокольчике оповещений появилось что-то новое." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Матч найден", + "admin_call": "Организатор вызывает вас", + "chat": "Новое сообщение | Новые сообщения", + "bell": "Новое уведомление | Новые уведомления" + }, "roster_picker": { "add_starter": "Добавить основного", "add_substitute": "Добавить запасного", diff --git a/i18n/locales/sv_SE.json b/i18n/locales/sv_SE.json index 640d25526..b1bfd6780 100644 --- a/i18n/locales/sv_SE.json +++ b/i18n/locales/sv_SE.json @@ -1829,6 +1829,26 @@ "to": "Till", "reset": "Återställ", "incomplete": "Ange både en start- och en sluttid för att spara tysta timmar." + }, + "tab_flash": { + "title": "Webbläsarflik", + "description": "Låter flikens titel och ikon blinka när något behöver dig medan den ligger i bakgrunden. Sparas på den här enheten.", + "match_found": { + "title": "Match hittad", + "description": "En match är redo och väntar på att du accepterar." + }, + "admin_call": { + "title": "Arrangörssamtal", + "description": "En matcharrangör börjar prata med dig via din kamerasida." + }, + "chat": { + "title": "Chattmeddelanden", + "description": "Ett nytt meddelande i en konversation, en lobby eller ett turneringsrum. Chatten från själva matchen i spelet räknas inte." + }, + "bell": { + "title": "Aviseringar", + "description": "Något nytt landar i din aviseringsklocka." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Match hittad", + "admin_call": "En arrangör ringer", + "chat": "Nytt meddelande | Nya meddelanden", + "bell": "Ny avisering | Nya aviseringar" + }, "roster_picker": { "add_starter": "Lägg till ordinarie", "add_substitute": "Lägg till reserv", diff --git a/i18n/locales/tr_TR.json b/i18n/locales/tr_TR.json index c2036adee..b79aec62d 100644 --- a/i18n/locales/tr_TR.json +++ b/i18n/locales/tr_TR.json @@ -1829,6 +1829,26 @@ "to": "Bitiş", "reset": "Sıfırla", "incomplete": "Sessiz saatleri kaydetmek için hem başlangıç hem bitiş saatini gir." + }, + "tab_flash": { + "title": "Tarayıcı sekmesi", + "description": "Bu sekme arka plandayken seni bekleyen bir şey olduğunda sekmenin başlığını ve simgesini yanıp söndürür. Bu cihaza kaydedilir.", + "match_found": { + "title": "Maç bulundu", + "description": "Bir maç hazır ve kabul etmeni bekliyor." + }, + "admin_call": { + "title": "Organizatör görüşmeleri", + "description": "Bir maç organizatörü kamera sayfan üzerinden seninle konuşmaya başladığında." + }, + "chat": { + "title": "Sohbet mesajları", + "description": "Bir konuşmada, lobide veya turnuva odasında yeni bir mesaj. Maçın oyun içi sohbeti dahil değildir." + }, + "bell": { + "title": "Bildirimler", + "description": "Bildirim ziline yeni bir şey geldiğinde." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Maç bulundu", + "admin_call": "Bir organizatör seni arıyor", + "chat": "Yeni mesaj | Yeni mesajlar", + "bell": "Yeni bildirim | Yeni bildirimler" + }, "roster_picker": { "add_starter": "İlk 5'e ekle", "add_substitute": "Yedek ekle", diff --git a/i18n/locales/uk_UA.json b/i18n/locales/uk_UA.json index b8348405b..5a2ae4ac4 100644 --- a/i18n/locales/uk_UA.json +++ b/i18n/locales/uk_UA.json @@ -1829,6 +1829,26 @@ "to": "До", "reset": "Скинути", "incomplete": "Задайте час початку та закінчення, щоб зберегти тихі години." + }, + "tab_flash": { + "title": "Вкладка браузера", + "description": "Заголовок і значок цієї вкладки блимають, коли щось потребує вашої уваги, а вкладка у фоні. Зберігається на цьому пристрої.", + "match_found": { + "title": "Матч знайдено", + "description": "Матч готовий і чекає, поки ви його приймете." + }, + "admin_call": { + "title": "Дзвінки організатора", + "description": "Організатор матчу починає говорити з вами через сторінку камери." + }, + "chat": { + "title": "Повідомлення чату", + "description": "Нове повідомлення в розмові, лобі чи турнірній кімнаті. Внутрішньоігровий чат матчу не враховується." + }, + "bell": { + "title": "Сповіщення", + "description": "У дзвіночку сповіщень з'явилося щось нове." + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "Матч знайдено", + "admin_call": "Організатор вам телефонує", + "chat": "Нове повідомлення | Нові повідомлення", + "bell": "Нове сповіщення | Нові сповіщення" + }, "roster_picker": { "add_starter": "Додати основного", "add_substitute": "Додати запасного", diff --git a/i18n/locales/zh_Hans.json b/i18n/locales/zh_Hans.json index 4669b5e94..13882c9a5 100644 --- a/i18n/locales/zh_Hans.json +++ b/i18n/locales/zh_Hans.json @@ -1829,6 +1829,26 @@ "to": "结束", "reset": "重置", "incomplete": "请同时设置开始和结束时间才能保存免打扰时段。" + }, + "tab_flash": { + "title": "浏览器标签页", + "description": "当此标签页在后台且有事需要你处理时,闪烁标签页的标题和图标。保存在此设备上。", + "match_found": { + "title": "已找到比赛", + "description": "比赛已就绪,正在等待你接受。" + }, + "admin_call": { + "title": "主办方通话", + "description": "比赛主办方开始通过你的摄像头页面与你通话时。" + }, + "chat": { + "title": "聊天消息", + "description": "会话、大厅或锦标赛房间中的新消息。不包括比赛中的游戏内聊天。" + }, + "bell": { + "title": "通知", + "description": "通知铃铛中出现新内容时。" + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "已找到比赛", + "admin_call": "主办方正在呼叫你", + "chat": "新消息 | 新消息", + "bell": "新通知 | 新通知" + }, "roster_picker": { "add_starter": "添加首发", "add_substitute": "添加替补", diff --git a/i18n/locales/zh_Hant.json b/i18n/locales/zh_Hant.json index 630544d18..b217a52ec 100644 --- a/i18n/locales/zh_Hant.json +++ b/i18n/locales/zh_Hant.json @@ -1829,6 +1829,26 @@ "to": "結束", "reset": "重設", "incomplete": "請同時設定開始與結束時間才能儲存勿擾時段。" + }, + "tab_flash": { + "title": "瀏覽器分頁", + "description": "當此分頁在背景且有事需要你處理時,閃爍分頁的標題和圖示。儲存在此裝置上。", + "match_found": { + "title": "已找到比賽", + "description": "比賽已就緒,正在等待你接受。" + }, + "admin_call": { + "title": "主辦方通話", + "description": "比賽主辦方開始透過你的攝影機頁面與你通話時。" + }, + "chat": { + "title": "聊天訊息", + "description": "對話、大廳或錦標賽房間中的新訊息。不包括比賽中的遊戲內聊天。" + }, + "bell": { + "title": "通知", + "description": "通知鈴鐺中出現新內容時。" + } } }, "notifications": { @@ -5513,6 +5533,12 @@ } } }, + "tab_flash": { + "match_found": "已找到比賽", + "admin_call": "主辦方正在呼叫你", + "chat": "新訊息 | 新訊息", + "bell": "新通知 | 新通知" + }, "roster_picker": { "add_starter": "新增先發", "add_substitute": "新增替補", diff --git a/pages/settings/notification-preferences.vue b/pages/settings/notification-preferences.vue index 868e4abc2..ad77f9993 100644 --- a/pages/settings/notification-preferences.vue +++ b/pages/settings/notification-preferences.vue @@ -11,6 +11,11 @@ import { tacticalSectionTickClasses, } from "~/utilities/tacticalClasses"; import { e_player_roles_enum } from "~/generated/zeus"; +import { + TAB_FLASH_KINDS, + useTabFlashSettings, + type TabFlashKind, +} from "~/composables/useTabFlashSettings"; const { t, te } = useI18n(); const authStore = useAuthStore(); @@ -218,6 +223,42 @@ const handlePushToggle = async (enabled: boolean) => { }); }; +const { enabled: tabFlashEnabled, setEnabled: setTabFlashEnabled } = + useTabFlashSettings(); + +const tabFlashCopy = computed< + Record +>(() => ({ + match_found: { + title: t( + "pages.settings.notification_preferences.tab_flash.match_found.title", + ), + description: t( + "pages.settings.notification_preferences.tab_flash.match_found.description", + ), + }, + admin_call: { + title: t( + "pages.settings.notification_preferences.tab_flash.admin_call.title", + ), + description: t( + "pages.settings.notification_preferences.tab_flash.admin_call.description", + ), + }, + chat: { + title: t("pages.settings.notification_preferences.tab_flash.chat.title"), + description: t( + "pages.settings.notification_preferences.tab_flash.chat.description", + ), + }, + bell: { + title: t("pages.settings.notification_preferences.tab_flash.bell.title"), + description: t( + "pages.settings.notification_preferences.tab_flash.bell.description", + ), + }, +})); + const handlePreferenceToggle = async ( channel: "push" | "in_app", key: string, @@ -451,6 +492,41 @@ const handlePreferenceToggle = async ( + +
+
+ + {{ $t("pages.settings.notification_preferences.tab_flash.title") }} +
+

+ {{ + $t("pages.settings.notification_preferences.tab_flash.description") + }} +

+
+ +
+
+
diff --git a/plugins/tab-flash.client.ts b/plugins/tab-flash.client.ts new file mode 100644 index 000000000..6343bf103 --- /dev/null +++ b/plugins/tab-flash.client.ts @@ -0,0 +1,98 @@ +import { watch } from "vue"; +import { installTabFlash, useTabFlash } from "~/composables/useTabFlash"; +import { useAuthStore } from "~/stores/AuthStore"; +import { useMatchmakingStore } from "~/stores/MatchmakingStore"; +import { useNotificationStore } from "~/stores/NotificationStore"; + +// The bell's subscriptions start at zero and land a moment after sign-in, so a +// rise inside this window is the bell loading, not something new arriving. +const BELL_SETTLE_MS = 10_000; + +// Flashes the tab's title and icon while it is hidden. Chat signals from the +// chat hooks themselves (useChatTabSetup, useIncomingDirectMessages) and an +// organizer call from useCameraTalkback; this owns everything that can be read +// off a store. Never touches the app badge, which follows the bell on its own. +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.hook("app:mounted", () => { + if (window.location.pathname.startsWith("/chat/")) { + return; + } + + installTabFlash(); + + nuxtApp.runWithContext(() => { + const flash = useTabFlash(); + const authStore = useAuthStore(); + const matchmakingStore = useMatchmakingStore(); + const notificationStore = useNotificationStore(); + + watch( + () => matchmakingStore.joinedMatchmakingQueues?.confirmation, + (confirmation) => { + if ( + !confirmation || + confirmation.isReady || + confirmation.matchId + ) { + flash.clear("match_found"); + return; + } + + flash.signal("match_found", confirmation.confirmationId); + }, + { immediate: true }, + ); + + let bellSettlesAt = 0; + let bellBaseline: number | null = null; + let bellSignalled = 0; + + const takeBellBaseline = () => { + bellBaseline = notificationStore.unreadNonChatNotificationCount; + bellSignalled = 0; + }; + + watch( + () => authStore.me?.steam_id, + (steamId) => { + bellSettlesAt = steamId ? Date.now() + BELL_SETTLE_MS : 0; + }, + { immediate: true }, + ); + + if (document.visibilityState === "hidden") { + takeBellBaseline(); + } + + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") { + takeBellBaseline(); + return; + } + + bellBaseline = null; + }); + + // Only what arrived since the tab was hidden counts; a drop (read on + // another device, or preferences loading) never flashes. + watch( + () => notificationStore.unreadNonChatNotificationCount, + (count) => { + if (bellBaseline === null) { + return; + } + + if (Date.now() < bellSettlesAt) { + takeBellBaseline(); + return; + } + + while (bellSignalled < count - bellBaseline) { + bellSignalled += 1; + flash.signal("bell"); + } + }, + ); + }); + }); +}); diff --git a/stores/NotificationStore.ts b/stores/NotificationStore.ts index c3394cd82..c769a6cc2 100644 --- a/stores/NotificationStore.ts +++ b/stores/NotificationStore.ts @@ -357,6 +357,12 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { .map((n) => n.entity_id), ); + // The bell without its chat rows, which the chat tabs' unread already counts. + const unreadNonChatNotificationCount = computed( + () => + unreadNotificationCount.value - unreadChatNotificationRooms.value.length, + ); + const stackedNotifications = computed(() => { const groups = new Map(); const singles: Notification[] = []; @@ -722,6 +728,7 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { stackedNotifications, unreadNotificationCount, unreadChatNotificationRooms, + unreadNonChatNotificationCount, hasNotifications, hasPersonalNotifications, hasAdminNotifications, diff --git a/tests/composables/useTabFlash.spec.ts b/tests/composables/useTabFlash.spec.ts new file mode 100644 index 000000000..973feb61e --- /dev/null +++ b/tests/composables/useTabFlash.spec.ts @@ -0,0 +1,407 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { nextTick } from "vue"; +import { installTabFlash, useTabFlash } from "~/composables/useTabFlash"; +import { faviconOverride } from "~/composables/useBranding"; +import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; +import { useMatchmakingStore } from "~/stores/MatchmakingStore"; +import { useNotificationStore } from "~/stores/NotificationStore"; + +const BASE_TITLE = "Matches | 5Stack"; +const SETTING_KEYS = ["match-found", "admin-call", "chat", "bell"]; + +let visibility: DocumentVisibilityState = "visible"; + +function setVisibility(state: DocumentVisibilityState) { + visibility = state; + document.dispatchEvent(new Event("visibilitychange")); +} + +// Fake timers hold back anything scheduled on a timer, so only drain the +// microtask queue the icon's promise chain runs on. +async function drainMicrotasks() { + for (let i = 0; i < 20; i++) { + await Promise.resolve(); + } +} + +beforeEach(() => { + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => visibility, + }); + installTabFlash(); + document.title = BASE_TITLE; + vi.useFakeTimers(); +}); + +afterEach(() => { + setVisibility("visible"); + vi.useRealTimers(); + delete (document as { visibilityState?: unknown }).visibilityState; + for (const key of SETTING_KEYS) { + localStorage.removeItem(`5stack:tab-flash:${key}`); + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("useTabFlash title", () => { + it("ignores signals while the tab is visible", () => { + const flash = useTabFlash(); + + flash.signal("chat", "visible-1"); + flash.signal("bell"); + vi.advanceTimersByTime(3000); + + expect(document.title).toBe(BASE_TITLE); + expect(flash.counts.chat).toBe(0); + expect(flash.counts.bell).toBe(0); + }); + + it("alternates a counted title with the page's own while hidden", () => { + setVisibility("hidden"); + useTabFlash().signal("chat", "hidden-1"); + + expect(document.title).toBe("(1) New message"); + vi.advanceTimersByTime(1000); + expect(document.title).toBe(BASE_TITLE); + vi.advanceTimersByTime(1000); + expect(document.title).toBe("(1) New message"); + }); + + it("counts every kind and names the most urgent one", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signal("bell"); + expect(document.title).toBe("(1) New notification"); + + flash.signal("chat", "priority-1"); + expect(document.title).toBe("(2) New messages"); + + flash.signal("match_found", "priority-confirmation"); + expect(document.title).toBe("(3) Match found"); + + flash.signal("admin_call"); + expect(document.title).toBe("(4) An organizer is calling"); + }); + + it("updates the title in place when a count changes mid-blink", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signal("chat", "mid-blink-1"); + vi.advanceTimersByTime(1000); + expect(document.title).toBe(BASE_TITLE); + + flash.signal("chat", "mid-blink-2"); + expect(document.title).toBe(BASE_TITLE); + vi.advanceTimersByTime(1000); + expect(document.title).toBe("(2) New messages"); + }); + + it("stops, resets and restores the title once the tab is visible", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + flash.signal("chat", "restore-1"); + flash.signal("match_found", "restore-confirmation"); + + setVisibility("visible"); + + expect(document.title).toBe(BASE_TITLE); + expect(flash.counts.chat).toBe(0); + expect(flash.counts.match_found).toBe(0); + expect(faviconOverride.value).toBeNull(); + vi.advanceTimersByTime(5000); + expect(document.title).toBe(BASE_TITLE); + + setVisibility("hidden"); + flash.signal("chat", "restore-2"); + expect(document.title).toBe("(1) New message"); + }); + + it("adopts a title unhead writes mid-flash and restores that one", () => { + setVisibility("hidden"); + useTabFlash().signal("chat", "unhead-1"); + + document.title = "Inferno | 5Stack"; + vi.advanceTimersByTime(1000); + expect(document.title).toBe("Inferno | 5Stack"); + vi.advanceTimersByTime(1000); + expect(document.title).toBe("(1) New message"); + + setVisibility("visible"); + expect(document.title).toBe("Inferno | 5Stack"); + }); + + it("holds the alert title steady for reduced motion", () => { + vi.spyOn(window, "matchMedia").mockImplementation( + (query: string) => + ({ + matches: query.includes("prefers-reduced-motion"), + media: query, + }) as MediaQueryList, + ); + setVisibility("hidden"); + useTabFlash().signal("chat", "reduced-1"); + + vi.advanceTimersByTime(1000); + expect(document.title).toBe("(1) New message"); + vi.advanceTimersByTime(1000); + expect(document.title).toBe("(1) New message"); + + setVisibility("visible"); + expect(document.title).toBe(BASE_TITLE); + }); + + it("respects a kind switched off in settings", () => { + localStorage.setItem("5stack:tab-flash:chat", "false"); + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signal("chat", "off-1"); + expect(document.title).toBe(BASE_TITLE); + expect(flash.counts.chat).toBe(0); + + flash.signal("bell"); + expect(document.title).toBe("(1) New notification"); + }); + + it("counts a message once however many handlers report it", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signal("chat", "dupe-1"); + flash.signal("chat", "dupe-1"); + + expect(flash.counts.chat).toBe(1); + expect(document.title).toBe("(1) New message"); + }); + + it("never flashes for something already seen while visible", () => { + const flash = useTabFlash(); + flash.signal("match_found", "seen-confirmation"); + + setVisibility("hidden"); + flash.signal("match_found", "seen-confirmation"); + + expect(document.title).toBe(BASE_TITLE); + }); + + it("drops a cleared kind and stops once nothing is left", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + flash.signal("match_found", "clear-confirmation"); + flash.signal("chat", "clear-1"); + expect(document.title).toBe("(2) Match found"); + + flash.clear("match_found"); + expect(document.title).toBe("(1) New message"); + + flash.clear("chat"); + expect(document.title).toBe(BASE_TITLE); + vi.advanceTimersByTime(3000); + expect(document.title).toBe(BASE_TITLE); + }); +}); + +// The Nuxt app boots with its own copy of the composable's module, so these go +// through the plugin and read the title rather than the test's own counts. +describe("tab flash sources", () => { + it("flashes a new match confirmation and clears it once ready", async () => { + const matchmaking = useMatchmakingStore(); + setVisibility("hidden"); + + const confirmation = { + matchId: "", + isReady: false, + expiresAt: new Date().toISOString(), + confirmed: 0, + confirmationId: "store-confirmation-1", + type: "Competitive", + region: "us", + players: 10, + }; + + matchmaking.joinedMatchmakingQueues = { confirmation } as never; + await nextTick(); + expect(document.title).toBe("(1) Match found"); + + matchmaking.joinedMatchmakingQueues = { + confirmation: { ...confirmation, confirmed: 3 }, + } as never; + await nextTick(); + expect(document.title).toBe("(1) Match found"); + + matchmaking.joinedMatchmakingQueues = { + confirmation: { ...confirmation, isReady: true }, + } as never; + await nextTick(); + expect(document.title).toBe(BASE_TITLE); + + matchmaking.joinedMatchmakingQueues = {} as never; + }); + + it("flashes only bell notifications that arrive after the tab is hidden", async () => { + const notifications = useNotificationStore(); + notifications.team_invites = [{ id: "a" }, { id: "b" }]; + await nextTick(); + + setVisibility("hidden"); + await nextTick(); + vi.advanceTimersByTime(3000); + expect(document.title).toBe(BASE_TITLE); + + notifications.team_invites = [{ id: "b" }]; + await nextTick(); + expect(document.title).toBe(BASE_TITLE); + + notifications.team_invites = [{ id: "b" }, { id: "c" }, { id: "d" }]; + await nextTick(); + expect(document.title).toBe("(1) New notification"); + + notifications.team_invites = []; + }); +}); + +describe("tab flash icon", () => { + type FakeContext = Record & { + fillText: ReturnType; + }; + + function fakeCanvas() { + const context: FakeContext = { + clearRect: vi.fn(), + drawImage: vi.fn(), + measureText: vi.fn(() => ({ width: 18 })), + beginPath: vi.fn(), + arc: vi.fn(), + closePath: vi.fn(), + fill: vi.fn(), + fillText: vi.fn(), + }; + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( + context as never, + ); + vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue( + "data:image/png;base64,ALERT", + ); + return context; + } + + function fakeImages(fails: (src: string) => boolean) { + const loaded: Array<{ src: string; crossOrigin: string | null }> = []; + + class FakeImage { + crossOrigin: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + set src(value: string) { + loaded.push({ src: value, crossOrigin: this.crossOrigin }); + queueMicrotask(() => { + if (fails(value)) { + this.onerror?.(); + return; + } + this.onload?.(); + }); + } + } + + vi.stubGlobal("Image", FakeImage); + return loaded; + } + + function brandFavicon(version: string) { + useApplicationSettingsStore().settings = [ + { name: "public.favicon_url", value: version }, + ]; + } + + afterEach(() => { + useApplicationSettingsStore().settings = []; + }); + + it("falls back to title-only flashing when there is no canvas", async () => { + brandFavicon("no-canvas"); + vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null); + setVisibility("hidden"); + + useTabFlash().signal("chat", "no-canvas-1"); + await drainMicrotasks(); + + expect(faviconOverride.value).toBeNull(); + expect(document.title).toBe("(1) New message"); + }); + + it("draws the count over the branded icon through CORS", async () => { + brandFavicon("cors-ok"); + const context = fakeCanvas(); + const loaded = fakeImages(() => false); + setVisibility("hidden"); + + useTabFlash().signal("chat", "cors-ok-1"); + await drainMicrotasks(); + + expect(loaded).toHaveLength(1); + expect(loaded[0].src).toMatch( + /\/branding\/favicon\?v=cors-ok&tab-flash=1$/, + ); + expect(loaded[0].crossOrigin).toBe("anonymous"); + expect(context.fillText).toHaveBeenCalledWith("1", expect.any(Number), 21); + expect(faviconOverride.value).toBe("data:image/png;base64,ALERT"); + + setVisibility("visible"); + expect(faviconOverride.value).toBeNull(); + }); + + it("falls back to the bundled icon when the branded one won't load", async () => { + brandFavicon("cors-broken"); + fakeCanvas(); + const loaded = fakeImages((src) => src.includes("/branding/")); + setVisibility("hidden"); + + useTabFlash().signal("chat", "cors-broken-1"); + await drainMicrotasks(); + + expect(loaded.map((image) => image.src)).toEqual([ + expect.stringContaining("/branding/favicon?v=cors-broken&tab-flash=1"), + "/favicon/64.png", + ]); + expect(faviconOverride.value).toBe("data:image/png;base64,ALERT"); + }); + + it("caps the icon's count at 9+", async () => { + brandFavicon("cap"); + const context = fakeCanvas(); + fakeImages(() => false); + setVisibility("hidden"); + const flash = useTabFlash(); + + for (let i = 0; i < 12; i++) { + flash.signal("chat", `cap-${i}`); + } + await drainMicrotasks(); + + expect(document.title).toBe("(12) New messages"); + expect(context.fillText).toHaveBeenLastCalledWith( + "9+", + expect.any(Number), + 21, + ); + }); + + it("never applies an icon that finishes loading after the flash stopped", async () => { + brandFavicon("late"); + fakeCanvas(); + fakeImages(() => false); + setVisibility("hidden"); + + useTabFlash().signal("chat", "late-1"); + setVisibility("visible"); + await drainMicrotasks(); + + expect(faviconOverride.value).toBeNull(); + }); +}); diff --git a/tests/pages/settings/notification-preferences.spec.ts b/tests/pages/settings/notification-preferences.spec.ts index 881c46b53..8b60cc64e 100644 --- a/tests/pages/settings/notification-preferences.spec.ts +++ b/tests/pages/settings/notification-preferences.spec.ts @@ -100,3 +100,72 @@ describe("notification preferences install gate", () => { expect(wrapper.text()).not.toContain(ALREADY_INSTALLED); }); }); + +describe("notification preferences browser tab toggles", () => { + const storageKey = (kind: string) => `5stack:tab-flash:${kind}`; + + afterEach(() => { + for (const kind of ["match-found", "admin-call", "chat", "bell"]) { + localStorage.removeItem(storageKey(kind)); + } + }); + + async function mountTabFlashRows() { + emulateDevice({ userAgent: userAgents.desktopChrome }); + pwa = fakePwa({ showInstallPrompt: false }); + + const wrapper = await mountPage(); + const section = wrapper + .findAll("section") + .find((candidate) => candidate.text().includes("Browser tab")); + + expect(section).toBeDefined(); + + return section!.findAll("label").map((row) => ({ + title: row.find("span").text(), + toggle: row.find("[role='switch']"), + })); + } + + it("offers every kind, on by default, without push", async () => { + const rows = await mountTabFlashRows(); + + expect(rows.map((row) => row.title)).toEqual([ + "Match found", + "Organizer calls", + "Chat messages", + "Notifications", + ]); + for (const row of rows) { + expect(row.toggle.attributes("aria-checked")).toBe("true"); + } + }); + + it("saves a toggle on this device", async () => { + const rows = await mountTabFlashRows(); + const chat = rows.find((row) => row.title === "Chat messages")!; + + await chat.toggle.trigger("click"); + await flushPromises(); + + expect(localStorage.getItem(storageKey("chat"))).toBe("false"); + expect(chat.toggle.attributes("aria-checked")).toBe("false"); + }); + + it("reads a saved choice back", async () => { + localStorage.setItem(storageKey("bell"), "false"); + + const rows = await mountTabFlashRows(); + + expect( + rows + .find((row) => row.title === "Notifications")! + .toggle.attributes("aria-checked"), + ).toBe("false"); + expect( + rows + .find((row) => row.title === "Match found")! + .toggle.attributes("aria-checked"), + ).toBe("true"); + }); +}); From 25e260e778ca294a583c9c18ae687f29a959237a Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 18:58:36 -0400 Subject: [PATCH 2/5] chore: keep the tab flash hooks clear of #607 and #623 Chat signals dedupe on the message id, as #607's unread counting does, and the organizer-call signal moves from join() to poll(), so both open PRs merge without conflicts. --- composables/useCameraTalkback.ts | 7 +++++-- composables/useChatTabSetup.ts | 6 ++---- composables/useIncomingDirectMessages.ts | 10 ++-------- 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/composables/useCameraTalkback.ts b/composables/useCameraTalkback.ts index 4aaae4ee6..467d8f2fd 100644 --- a/composables/useCameraTalkback.ts +++ b/composables/useCameraTalkback.ts @@ -5,8 +5,8 @@ import { hangupPlayerTalk, negotiateWebRtc, } from "~/composables/useCameraApi"; -import { useIceServers } from "~/composables/useIceServers"; import { useTabFlash } from "~/composables/useTabFlash"; +import { useIceServers } from "~/composables/useIceServers"; // 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 @@ -69,7 +69,6 @@ export function useCameraTalkback(matchId: () => string) { await negotiateWebRtc(pc, cameraPlayerTalkUrl(matchId()), "include"); talking.value = true; - useTabFlash().signal("admin_call"); } catch { end(); } @@ -96,6 +95,10 @@ export function useCameraTalkback(matchId: () => string) { if (ready && !talking.value) { await join(); + + if (talking.value) { + useTabFlash().signal("admin_call"); + } } else if (!ready && talking.value) { end(); } diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 178e2664a..239794c94 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -2,11 +2,10 @@ import { watch, computed } from "vue"; import { useI18n } from "vue-i18n"; import { useChatTabs } from "~/composables/useChatTabs"; import { useChatReadState } from "~/composables/useChatReadState"; -import { useTabFlash } from "~/composables/useTabFlash"; import { useMatchLobbyStore } from "~/stores/MatchLobbyStore"; import { useAuthStore } from "~/stores/AuthStore"; import { e_player_roles_enum } from "~/generated/zeus"; -import socket, { chatMessageKey, type Lobby } from "~/web-sockets/Socket"; +import socket, { type Lobby } from "~/web-sockets/Socket"; export function useChatTabSetup() { const { t } = useI18n(); @@ -41,12 +40,11 @@ export function useChatTabSetup() { if (String(message?.from?.steam_id) === String(authStore.me?.steam_id)) { return; } - // Ahead of the on-screen check, which counts an open room as read even // while the browser tab is hidden. Match rooms relay in-game chat line // by line, the same reason their push is off by default. if (tab.type !== "match") { - useTabFlash().signal("chat", chatMessageKey(message)); + useTabFlash().signal("chat", message?.id); } const isOnScreen = diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index 908e11952..db8694278 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -1,9 +1,5 @@ import { watch } from "vue"; -import socket, { - chatMessageKey, - type LobbyMessage, -} from "~/web-sockets/Socket"; -import { useTabFlash } from "~/composables/useTabFlash"; +import socket, { type LobbyMessage } from "~/web-sockets/Socket"; import { directTabId, peerSteamId, @@ -110,9 +106,7 @@ export function useIncomingDirectMessages() { } // The room's own `lobby:chat` carries the same id when its tab is open. - if (data.message) { - useTabFlash().signal("chat", chatMessageKey(data.message)); - } + useTabFlash().signal("chat", data.message?.id); // Deliberately does not inject the message: opening the tab makes // useChatTabSetup join the room, and the join's history snapshot delivers From 101d92dd77d42d1ad092efe3843924ce0b23212f Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 19:21:11 -0400 Subject: [PATCH 3/5] bug: tab flash review fixes - Bell flashes count unread personal rows (invites, news and league scheduling each insert one), so one invite no longer reads as two and staff-only infrastructure rows don't flash. - The bell baseline waits for the notifications subscription's first delivery instead of a 10s settle window. - Layout-less windows the app opens (camera, replay, stream deck, match and file pop-outs) keep their own organizer-call signal but leave the store-driven alerts to the main window. - A branded icon that can't be drawn stays title-only instead of showing 5stack's icon; a failed draw is retried after a minute. - A talkback join that lands after the camera page is gone no longer signals. --- composables/useCameraTalkback.ts | 2 +- composables/useTabFlash.ts | 137 ++++++++++++++++++++------ composables/useTabFlashSettings.ts | 4 +- plugins/tab-flash.client.ts | 78 ++++----------- stores/NotificationStore.ts | 20 +++- tests/composables/useTabFlash.spec.ts | 97 +++++++++++++++--- 6 files changed, 227 insertions(+), 111 deletions(-) diff --git a/composables/useCameraTalkback.ts b/composables/useCameraTalkback.ts index 467d8f2fd..9ffc775e8 100644 --- a/composables/useCameraTalkback.ts +++ b/composables/useCameraTalkback.ts @@ -96,7 +96,7 @@ export function useCameraTalkback(matchId: () => string) { if (ready && !talking.value) { await join(); - if (talking.value) { + if (talking.value && !disposed) { useTabFlash().signal("admin_call"); } } else if (!ready && talking.value) { diff --git a/composables/useTabFlash.ts b/composables/useTabFlash.ts index fb2a0504e..4a9fa66b4 100644 --- a/composables/useTabFlash.ts +++ b/composables/useTabFlash.ts @@ -1,4 +1,10 @@ -import { reactive, readonly } from "vue"; +import { + getCurrentScope, + onScopeDispose, + reactive, + readonly, + watch, +} from "vue"; import { brandedFaviconHref, faviconOverride } from "~/composables/useBranding"; import { isTabFlashEnabled, @@ -12,8 +18,9 @@ const BLINK_MS = 1000; const SEEN_KEY_LIMIT = 500; const ICON_SIZE = 64; const ICON_LOAD_TIMEOUT_MS = 5000; +const ICON_RETRY_MS = 60_000; const DEFAULT_FAVICON = "/favicon.ico"; -const FALLBACK_ICON = "/favicon/64.png"; +const BUNDLED_ICON = "/favicon/64.png"; const COUNT_DOT_COLOR = "#ef4444"; const counts = reactive>({ @@ -147,20 +154,12 @@ async function showAlertIcon(label: string) { function alertIcon(href: string, label: string) { const cacheKey = `${href}|${label}`; - let icon = alertIcons.get(cacheKey); + const cached = alertIcons.get(cacheKey); - if (!icon) { - icon = drawAlertIcon(href, label); - alertIcons.set(cacheKey, icon); + if (cached) { + return cached; } - return icon; -} - -async function drawAlertIcon( - href: string, - label: string, -): Promise { const canvas = document.createElement("canvas"); canvas.width = ICON_SIZE; canvas.height = ICON_SIZE; @@ -173,30 +172,46 @@ async function drawAlertIcon( } if (!context) { - return null; + return Promise.resolve(null); } + const icon = drawAlertIcon(canvas, context, href, label); + alertIcons.set(cacheKey, icon); + + // One failed load (an API blip) shouldn't leave every later flash + // title-only, but it shouldn't be retried on every signal either. + void icon.then((drawn) => { + if (!drawn) { + setTimeout(() => alertIcons.delete(cacheKey), ICON_RETRY_MS); + } + }); + + return icon; +} + +async function drawAlertIcon( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, + href: string, + label: string, +): Promise { // The branded icon lives on the API's origin. The tab already fetched it // without CORS, and a cached copy of that response would taint the canvas, - // so the CORS request goes to a URL of its own. - const sources = + // so the CORS request goes to a URL of its own. If it still can't be drawn + // the flash stays title-only: the bundled icon is 5stack's, not the brand's. + const source = href === DEFAULT_FAVICON - ? [FALLBACK_ICON] - : [`${href}${href.includes("?") ? "&" : "?"}tab-flash=1`, FALLBACK_ICON]; - - for (const source of sources) { - try { - const image = await loadImage(source); - context.clearRect(0, 0, ICON_SIZE, ICON_SIZE); - context.drawImage(image, 0, 0, ICON_SIZE, ICON_SIZE); - drawCountDot(context, label); - return canvas.toDataURL("image/png"); - } catch { - // A failed load or a tainted canvas moves on to the next source. - } - } + ? BUNDLED_ICON + : `${href}${href.includes("?") ? "&" : "?"}tab-flash=1`; - return null; + try { + const image = await loadImage(source); + context.drawImage(image, 0, 0, ICON_SIZE, ICON_SIZE); + drawCountDot(context, label); + return canvas.toDataURL("image/png"); + } catch { + return null; + } } function loadImage(src: string): Promise { @@ -315,6 +330,66 @@ function onVisibilityChange() { stop(); } +// Counts what arrives after the tab is hidden, never what was already there: +// the baseline is taken on hide, or when the source first loads if the tab was +// hidden before that. A drop (read elsewhere, preferences loading) never +// flashes, and neither does climbing back to where it was. +export function trackBellCount( + read: () => { loaded: boolean; count: number }, +) { + let baseline: number | null = null; + let signalled = 0; + + const takeBaseline = () => { + const { loaded, count } = read(); + baseline = loaded ? count : null; + signalled = 0; + }; + + const onVisibilityChange = () => { + if (isHidden()) { + takeBaseline(); + return; + } + + baseline = null; + }; + + if (isHidden()) { + takeBaseline(); + } + + document.addEventListener("visibilitychange", onVisibilityChange); + + const stopWatching = watch(read, ({ loaded, count }) => { + if (!isHidden()) { + return; + } + + if (!loaded) { + baseline = null; + return; + } + + if (baseline === null) { + takeBaseline(); + return; + } + + while (signalled < count - baseline) { + signalled += 1; + signal("bell"); + } + }); + + if (getCurrentScope()) { + onScopeDispose(() => { + stopWatching(); + document.removeEventListener("visibilitychange", onVisibilityChange); + }); + } +} + // Only the main window installs this. The chat pop-out is a document of its // own, and flashing it too would double every alert. export function installTabFlash() { diff --git a/composables/useTabFlashSettings.ts b/composables/useTabFlashSettings.ts index 2cbc1be12..b78238fcb 100644 --- a/composables/useTabFlashSettings.ts +++ b/composables/useTabFlashSettings.ts @@ -47,9 +47,7 @@ export function useTabFlashSettings() { try { localStorage.setItem(STORAGE_KEYS[kind], String(value)); - } catch { - // Kept in memory above. - } + } catch {} } return { enabled, setEnabled }; diff --git a/plugins/tab-flash.client.ts b/plugins/tab-flash.client.ts index 6343bf103..07c30ff4d 100644 --- a/plugins/tab-flash.client.ts +++ b/plugins/tab-flash.client.ts @@ -1,17 +1,16 @@ import { watch } from "vue"; -import { installTabFlash, useTabFlash } from "~/composables/useTabFlash"; -import { useAuthStore } from "~/stores/AuthStore"; +import { + installTabFlash, + trackBellCount, + useTabFlash, +} from "~/composables/useTabFlash"; import { useMatchmakingStore } from "~/stores/MatchmakingStore"; import { useNotificationStore } from "~/stores/NotificationStore"; -// The bell's subscriptions start at zero and land a moment after sign-in, so a -// rise inside this window is the bell loading, not something new arriving. -const BELL_SETTLE_MS = 10_000; - // Flashes the tab's title and icon while it is hidden. Chat signals from the // chat hooks themselves (useChatTabSetup, useIncomingDirectMessages) and an -// organizer call from useCameraTalkback; this owns everything that can be read -// off a store. Never touches the app badge, which follows the bell on its own. +// organizer call from useCameraTalkback; this owns what can be read off a +// store. Never touches the app badge, which follows the bell on its own. export default defineNuxtPlugin((nuxtApp) => { nuxtApp.hook("app:mounted", () => { if (window.location.pathname.startsWith("/chat/")) { @@ -21,8 +20,15 @@ export default defineNuxtPlugin((nuxtApp) => { installTabFlash(); nuxtApp.runWithContext(() => { + // A layout-less window the app opened (camera, replay, stream deck, the + // match and file pop-outs) sits beside the main window, which already + // alerts for everything a store can tell it. It keeps only what it + // raises itself, like the camera page's organizer call. + if (window.opener && useRoute().meta.layout === false) { + return; + } + const flash = useTabFlash(); - const authStore = useAuthStore(); const matchmakingStore = useMatchmakingStore(); const notificationStore = useNotificationStore(); @@ -43,56 +49,10 @@ export default defineNuxtPlugin((nuxtApp) => { { immediate: true }, ); - let bellSettlesAt = 0; - let bellBaseline: number | null = null; - let bellSignalled = 0; - - const takeBellBaseline = () => { - bellBaseline = notificationStore.unreadNonChatNotificationCount; - bellSignalled = 0; - }; - - watch( - () => authStore.me?.steam_id, - (steamId) => { - bellSettlesAt = steamId ? Date.now() + BELL_SETTLE_MS : 0; - }, - { immediate: true }, - ); - - if (document.visibilityState === "hidden") { - takeBellBaseline(); - } - - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "hidden") { - takeBellBaseline(); - return; - } - - bellBaseline = null; - }); - - // Only what arrived since the tab was hidden counts; a drop (read on - // another device, or preferences loading) never flashes. - watch( - () => notificationStore.unreadNonChatNotificationCount, - (count) => { - if (bellBaseline === null) { - return; - } - - if (Date.now() < bellSettlesAt) { - takeBellBaseline(); - return; - } - - while (bellSignalled < count - bellBaseline) { - bellSignalled += 1; - flash.signal("bell"); - } - }, - ); + trackBellCount(() => ({ + loaded: notificationStore.notificationsLoaded, + count: notificationStore.unreadPersonalAlertCount, + })); }); }); }); diff --git a/stores/NotificationStore.ts b/stores/NotificationStore.ts index c769a6cc2..2a5ba939d 100644 --- a/stores/NotificationStore.ts +++ b/stores/NotificationStore.ts @@ -109,6 +109,7 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { const draft_invites = ref([]); const draftInvitesLoaded = ref(false); const notifications = ref([]); + const notificationsLoaded = ref(false); const seasonRebuilds = ref>([]); // Raw league seasons (with only the viewer's un-played brackets, filtered // server-side); actionability is derived below into `scheduleTasks` — a @@ -357,10 +358,18 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { .map((n) => n.entity_id), ); - // The bell without its chat rows, which the chat tabs' unread already counts. - const unreadNonChatNotificationCount = computed( + // One per thing that happened to the player. Invites, news and league + // scheduling each insert a row too, so counting rows rather than the bell's + // own lists sees them once; chat rows are left to the chat tabs' unread. + const unreadPersonalAlertCount = computed( () => - unreadNotificationCount.value - unreadChatNotificationRooms.value.length, + visibleNotifications.value.filter( + (n) => + !n.is_read && + n.role === "user" && + n.type !== e_notification_types_enum.ChatMessage && + n.type !== e_notification_types_enum.MatchChatMessage, + ).length, ); const stackedNotifications = computed(() => { @@ -589,6 +598,7 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { .subscribe({ next: ({ data }) => { notifications.value = data.notifications; + notificationsLoaded.value = true; }, }), ); @@ -687,6 +697,7 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { () => useAuthStore().me?.steam_id, (steamId) => { draftInvitesLoaded.value = false; + notificationsLoaded.value = false; if (steamId) { subscribeToAll(steamId); // Loaded here rather than only on the settings page: the bell filters @@ -728,7 +739,8 @@ export const useNotificationStore = defineStore("notifaicationStore", () => { stackedNotifications, unreadNotificationCount, unreadChatNotificationRooms, - unreadNonChatNotificationCount, + unreadPersonalAlertCount, + notificationsLoaded, hasNotifications, hasPersonalNotifications, hasAdminNotifications, diff --git a/tests/composables/useTabFlash.spec.ts b/tests/composables/useTabFlash.spec.ts index 973feb61e..320c294f0 100644 --- a/tests/composables/useTabFlash.spec.ts +++ b/tests/composables/useTabFlash.spec.ts @@ -1,10 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { nextTick } from "vue"; -import { installTabFlash, useTabFlash } from "~/composables/useTabFlash"; +import { effectScope, nextTick, ref } from "vue"; +import { + installTabFlash, + trackBellCount, + useTabFlash, +} from "~/composables/useTabFlash"; import { faviconOverride } from "~/composables/useBranding"; import { useApplicationSettingsStore } from "~/stores/ApplicationSettings"; import { useMatchmakingStore } from "~/stores/MatchmakingStore"; -import { useNotificationStore } from "~/stores/NotificationStore"; const BASE_TITLE = "Matches | 5Stack"; const SETTING_KEYS = ["match-found", "admin-call", "chat", "bell"]; @@ -242,25 +245,81 @@ describe("tab flash sources", () => { matchmaking.joinedMatchmakingQueues = {} as never; }); - it("flashes only bell notifications that arrive after the tab is hidden", async () => { - const notifications = useNotificationStore(); - notifications.team_invites = [{ id: "a" }, { id: "b" }]; - await nextTick(); +}); + +describe("trackBellCount", () => { + function trackFake(initial: { loaded: boolean; count: number }) { + const loaded = ref(initial.loaded); + const count = ref(initial.count); + const scope = effectScope(); + + scope.run(() => + trackBellCount(() => ({ loaded: loaded.value, count: count.value })), + ); + + return { loaded, count, stop: () => scope.stop() }; + } + + it("flashes only what arrives after the tab is hidden", async () => { + const bell = trackFake({ loaded: true, count: 2 }); setVisibility("hidden"); + bell.count.value = 1; + await nextTick(); + bell.count.value = 2; await nextTick(); - vi.advanceTimersByTime(3000); expect(document.title).toBe(BASE_TITLE); - notifications.team_invites = [{ id: "b" }]; + bell.count.value = 4; + await nextTick(); + expect(document.title).toBe("(2) New notifications"); + + bell.stop(); + }); + + it("never flashes what the first load delivers to a hidden tab", async () => { + setVisibility("hidden"); + const bell = trackFake({ loaded: false, count: 0 }); + + bell.count.value = 3; + bell.loaded.value = true; await nextTick(); expect(document.title).toBe(BASE_TITLE); - notifications.team_invites = [{ id: "b" }, { id: "c" }, { id: "d" }]; + bell.count.value = 4; await nextTick(); expect(document.title).toBe("(1) New notification"); - notifications.team_invites = []; + bell.stop(); + }); + + it("takes a fresh baseline every time the tab is hidden", async () => { + const bell = trackFake({ loaded: true, count: 0 }); + + setVisibility("hidden"); + bell.count.value = 1; + await nextTick(); + expect(document.title).toBe("(1) New notification"); + + setVisibility("visible"); + setVisibility("hidden"); + await nextTick(); + expect(document.title).toBe(BASE_TITLE); + + bell.stop(); + }); + + it("ignores rises while the tab is visible", async () => { + const bell = trackFake({ loaded: true, count: 0 }); + + bell.count.value = 5; + await nextTick(); + setVisibility("hidden"); + await nextTick(); + + expect(document.title).toBe(BASE_TITLE); + + bell.stop(); }); }); @@ -356,7 +415,7 @@ describe("tab flash icon", () => { expect(faviconOverride.value).toBeNull(); }); - it("falls back to the bundled icon when the branded one won't load", async () => { + it("stays title-only rather than show 5stack's icon on a brand", async () => { brandFavicon("cors-broken"); fakeCanvas(); const loaded = fakeImages((src) => src.includes("/branding/")); @@ -367,8 +426,20 @@ describe("tab flash icon", () => { expect(loaded.map((image) => image.src)).toEqual([ expect.stringContaining("/branding/favicon?v=cors-broken&tab-flash=1"), - "/favicon/64.png", ]); + expect(faviconOverride.value).toBeNull(); + expect(document.title).toBe("(1) New message"); + }); + + it("draws on the bundled icon when there is no branding", async () => { + fakeCanvas(); + const loaded = fakeImages(() => false); + setVisibility("hidden"); + + useTabFlash().signal("chat", "unbranded-1"); + await drainMicrotasks(); + + expect(loaded.map((image) => image.src)).toEqual(["/favicon/64.png"]); expect(faviconOverride.value).toBe("data:image/png;base64,ALERT"); }); From 468f9ed0261f9e21771c59ea14be9c3439503202 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 19:24:46 -0400 Subject: [PATCH 4/5] bug: don't flash the tab for in-game relayed chat lines In-game say_team lines are about to be relayed into match_team rooms, so a chat signal now skips any message with source "game", in any room. Match rooms stay left out entirely for APIs whose messages carry no source. --- composables/useChatTabSetup.ts | 7 ++----- composables/useIncomingDirectMessages.ts | 2 +- composables/useTabFlash.ts | 16 ++++++++++++++++ tests/composables/useTabFlash.spec.ts | 24 ++++++++++++++++++++++++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/composables/useChatTabSetup.ts b/composables/useChatTabSetup.ts index 239794c94..3472a6256 100644 --- a/composables/useChatTabSetup.ts +++ b/composables/useChatTabSetup.ts @@ -41,11 +41,8 @@ export function useChatTabSetup() { return; } // Ahead of the on-screen check, which counts an open room as read even - // while the browser tab is hidden. Match rooms relay in-game chat line - // by line, the same reason their push is off by default. - if (tab.type !== "match") { - useTabFlash().signal("chat", message?.id); - } + // while the browser tab is hidden. + useTabFlash().signalChat(tab.type, message); const isOnScreen = activeTabId.value === tab.id && diff --git a/composables/useIncomingDirectMessages.ts b/composables/useIncomingDirectMessages.ts index db8694278..1d706b987 100644 --- a/composables/useIncomingDirectMessages.ts +++ b/composables/useIncomingDirectMessages.ts @@ -106,7 +106,7 @@ export function useIncomingDirectMessages() { } // The room's own `lobby:chat` carries the same id when its tab is open. - useTabFlash().signal("chat", data.message?.id); + useTabFlash().signalChat("direct", data.message); // Deliberately does not inject the message: opening the tab makes // useChatTabSetup join the room, and the join's history snapshot delivers diff --git a/composables/useTabFlash.ts b/composables/useTabFlash.ts index 4a9fa66b4..1b59f118d 100644 --- a/composables/useTabFlash.ts +++ b/composables/useTabFlash.ts @@ -299,6 +299,21 @@ function signal(kind: TabFlashKind, key?: string) { safely(refresh); } +// Lines typed in-game are relayed into the web rooms one by one, and the +// player they reach is already reading them in the game -- the same reason the +// match chat push is off by default. Match rooms are left out whatever the +// source, which covers APIs that predate `source`. +function signalChat( + roomType: string, + message?: { id?: string; source?: string } | null, +) { + if (message?.source === "game" || roomType === "match") { + return; + } + + signal("chat", message?.id); +} + function clear(kind: TabFlashKind) { if (counts[kind] === 0) { return; @@ -405,6 +420,7 @@ export function useTabFlash() { return { counts: readonly(counts), signal, + signalChat, clear, }; } diff --git a/tests/composables/useTabFlash.spec.ts b/tests/composables/useTabFlash.spec.ts index 320c294f0..1ba24b9c8 100644 --- a/tests/composables/useTabFlash.spec.ts +++ b/tests/composables/useTabFlash.spec.ts @@ -191,6 +191,30 @@ describe("useTabFlash title", () => { expect(document.title).toBe(BASE_TITLE); }); + it("never flashes for a line relayed from in-game, in any room", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signalChat("match_team", { id: "relay-1", source: "game" }); + flash.signalChat("direct", { id: "relay-2", source: "game" }); + expect(document.title).toBe(BASE_TITLE); + + flash.signalChat("match_team", { id: "relay-3", source: "web" }); + expect(document.title).toBe("(1) New message"); + }); + + it("leaves out match rooms even without a source, for older APIs", () => { + setVisibility("hidden"); + const flash = useTabFlash(); + + flash.signalChat("match", { id: "legacy-1" }); + flash.signalChat("match", { id: "legacy-2", source: "web" }); + expect(document.title).toBe(BASE_TITLE); + + flash.signalChat("tournament", { id: "legacy-3" }); + expect(document.title).toBe("(1) New message"); + }); + it("drops a cleared kind and stops once nothing is left", () => { setVisibility("hidden"); const flash = useTabFlash(); From 7dafbf7253126257117a3955dad2b5ed0f101102 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Mon, 28 Sep 2026 20:40:38 -0400 Subject: [PATCH 5/5] test: prove the bell double count, icon retry, pop-out alerts and late talkback flash are fixed --- .../useCameraTalkbackFlash.spec.ts | 76 +++++++++++++++++++ tests/composables/useTabFlash.spec.ts | 22 ++++++ tests/plugins/tabFlash.spec.ts | 57 ++++++++++++++ tests/stores/unreadPersonalAlertCount.spec.ts | 73 ++++++++++++++++++ 4 files changed, 228 insertions(+) create mode 100644 tests/composables/useCameraTalkbackFlash.spec.ts create mode 100644 tests/plugins/tabFlash.spec.ts create mode 100644 tests/stores/unreadPersonalAlertCount.spec.ts diff --git a/tests/composables/useCameraTalkbackFlash.spec.ts b/tests/composables/useCameraTalkbackFlash.spec.ts new file mode 100644 index 000000000..cc5079574 --- /dev/null +++ b/tests/composables/useCameraTalkbackFlash.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { effectScope } from "vue"; +import { flushPromises } from "@vue/test-utils"; +import { useCameraTalkback } from "~/composables/useCameraTalkback"; +import { negotiateWebRtc } from "~/composables/useCameraApi"; + +const flash = vi.hoisted(() => ({ signal: vi.fn(), clear: vi.fn() })); + +vi.mock("~/composables/useTabFlash", () => ({ + useTabFlash: () => flash, +})); + +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() {} +} + +function mountTalkback() { + const scope = effectScope(); + const talkback = scope.run(() => useCameraTalkback(() => "m-1"))!; + + return { scope, talkback }; +} + +beforeEach(() => { + vi.stubGlobal("RTCPeerConnection", FakePeerConnection); + flash.signal.mockClear(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("useCameraTalkback tab flash", () => { + it("flashes the tab once the organizer's talkback goes live", async () => { + const { scope, talkback } = mountTalkback(); + + talkback.start(); + await flushPromises(); + + expect(flash.signal).toHaveBeenCalledWith("admin_call"); + + scope.stop(); + }); + + it("does not flash for a talkback that connects after the page is gone", async () => { + let connect!: () => void; + vi.mocked(negotiateWebRtc).mockImplementationOnce( + () => + new Promise((resolve) => { + connect = resolve; + }), + ); + const { scope, talkback } = mountTalkback(); + + talkback.start(); + await flushPromises(); + scope.stop(); + connect(); + await flushPromises(); + + expect(flash.signal).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/composables/useTabFlash.spec.ts b/tests/composables/useTabFlash.spec.ts index 1ba24b9c8..9aa686f3e 100644 --- a/tests/composables/useTabFlash.spec.ts +++ b/tests/composables/useTabFlash.spec.ts @@ -487,6 +487,28 @@ describe("tab flash icon", () => { ); }); + it("retries an icon that failed to draw a minute later", async () => { + brandFavicon("retry"); + fakeCanvas(); + let failing = true; + const loaded = fakeImages(() => failing); + setVisibility("hidden"); + + useTabFlash().signal("chat", "retry-1"); + await drainMicrotasks(); + expect(faviconOverride.value).toBeNull(); + + setVisibility("visible"); + failing = false; + vi.advanceTimersByTime(60_000); + setVisibility("hidden"); + useTabFlash().signal("chat", "retry-2"); + await drainMicrotasks(); + + expect(loaded).toHaveLength(2); + expect(faviconOverride.value).toBe("data:image/png;base64,ALERT"); + }); + it("never applies an icon that finishes loading after the flash stopped", async () => { brandFavicon("late"); fakeCanvas(); diff --git a/tests/plugins/tabFlash.spec.ts b/tests/plugins/tabFlash.spec.ts new file mode 100644 index 000000000..30a088f19 --- /dev/null +++ b/tests/plugins/tabFlash.spec.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import tabFlashPlugin from "~/plugins/tab-flash.client"; + +const flash = vi.hoisted(() => ({ + installTabFlash: vi.fn(), + trackBellCount: vi.fn(), + signal: vi.fn(), + clear: vi.fn(), +})); + +vi.mock("~/composables/useTabFlash", () => ({ + installTabFlash: flash.installTabFlash, + trackBellCount: flash.trackBellCount, + useTabFlash: () => ({ signal: flash.signal, clear: flash.clear }), +})); + +function runPlugin() { + let mounted: (() => void) | undefined; + const nuxtApp = { + hook: (name: string, callback: () => void) => { + if (name === "app:mounted") { + mounted = callback; + } + }, + runWithContext: (fn: () => unknown) => useNuxtApp().runWithContext(fn), + }; + (tabFlashPlugin as unknown as (app: typeof nuxtApp) => void)(nuxtApp); + mounted!(); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + delete useRoute().meta.layout; +}); + +describe("tab-flash plugin", () => { + it("tracks the bell in the main window", () => { + runPlugin(); + + expect(flash.trackBellCount).toHaveBeenCalledTimes(1); + }); + + it("leaves store alerts to the main window in a layout-less pop-out", () => { + vi.stubGlobal("opener", {}); + useRoute().meta.layout = false; + + runPlugin(); + + expect(flash.installTabFlash).toHaveBeenCalledTimes(1); + expect(flash.trackBellCount).not.toHaveBeenCalled(); + expect(flash.clear).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/stores/unreadPersonalAlertCount.spec.ts b/tests/stores/unreadPersonalAlertCount.spec.ts new file mode 100644 index 000000000..30c4459b5 --- /dev/null +++ b/tests/stores/unreadPersonalAlertCount.spec.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushPromises } from "@vue/test-utils"; +import { useAuthStore } from "~/stores/AuthStore"; +import { useNotificationStore } from "~/stores/NotificationStore"; + +const ME = "76561198000000001"; + +let observers: Array<{ root: string; observer: any }> = []; + +function serve() { + const client = useApollo().clients!.default; + vi.spyOn(client, "subscribe").mockImplementation( + (options: any) => + ({ + subscribe(observer: any) { + observers.push({ + root: options.query.definitions[0].selectionSet.selections[0].name + .value, + observer, + }); + return { unsubscribe() {}, closed: false }; + }, + }) as any, + ); +} + +function row(id: string, type: string, role: string, entity_id: string) { + return { + id, + title: type, + message: "", + steam_id: role === "user" ? ME : null, + type, + role, + entity_id, + is_read: false, + deletable: true, + created_at: "2026-09-28T12:00:00Z", + actions: null, + }; +} + +afterEach(async () => { + useAuthStore().me = undefined; + await flushPromises(); + observers = []; + vi.restoreAllMocks(); +}); + +describe("NotificationStore unreadPersonalAlertCount", () => { + it("counts an invite once and leaves out chat and staff rows", async () => { + serve(); + const store = useNotificationStore(); + useAuthStore().me = { steam_id: ME } as any; + await flushPromises(); + + const feed = observers.find((entry) => entry.root === "notifications"); + expect(feed, "notifications subscription").toBeDefined(); + feed!.observer.next({ + data: { + notifications: [ + row("invite", "TeamInvite", "user", "team-1"), + row("chat", "ChatMessage", "user", "lobby-1"), + row("node", "GameNodeStatus", "administrator", "node-1"), + ], + }, + }); + store.team_invites = [{ id: "team-invite-1" }]; + await flushPromises(); + + expect(store.unreadPersonalAlertCount).toBe(1); + }); +});