import { initializeApp, getApps } from "firebase/app"; import { getMessaging, getToken, isSupported, onMessage, type MessagePayload } from "firebase/messaging"; import { deletePushToken, upsertPushToken } from "../api/notifications"; import { showNotificationViaServiceWorker } from "./webNotifications"; const WEB_PUSH_TOKEN_KEY = "bm_web_push_token"; let foregroundListenerAttached = false; export async function ensureWebPushRegistration(): Promise { const config = getFirebaseConfig(); const vapidKey = normalizeVapidKey(import.meta.env.VITE_FIREBASE_VAPID_KEY); if (!config || !vapidKey) { return; } if (!(await isSupported())) { return; } if (!("Notification" in window)) { return; } if (Notification.permission === "default") { await Notification.requestPermission(); } if (Notification.permission !== "granted") { return; } if (!("serviceWorker" in navigator)) { return; } const registration = await navigator.serviceWorker.ready; const app = getApps()[0] ?? initializeApp(config); const messaging = getMessaging(app); let token: string | null = null; try { token = await getToken(messaging, { vapidKey, serviceWorkerRegistration: registration, }); } catch (error) { if (error instanceof DOMException && error.name === "InvalidAccessError") { console.error( "[web-push] Invalid VAPID key format. Check VITE_FIREBASE_VAPID_KEY in web env.", ); return; } throw error; } if (!token) { return; } const previous = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY); if (previous && previous !== token) { await deletePushToken({ platform: "web", token: previous }).catch(() => undefined); } await upsertPushToken({ platform: "web", token, app_version: "web" }); window.localStorage.setItem(WEB_PUSH_TOKEN_KEY, token); if (!foregroundListenerAttached) { foregroundListenerAttached = true; onMessage(messaging, (payload) => { void showForegroundNotification(payload); }); } } export async function unregisterWebPushToken(): Promise { const token = window.localStorage.getItem(WEB_PUSH_TOKEN_KEY); if (!token) { return; } await deletePushToken({ platform: "web", token }).catch(() => undefined); window.localStorage.removeItem(WEB_PUSH_TOKEN_KEY); } async function showForegroundNotification(payload: MessagePayload): Promise { const data = payload.data ?? {}; const chatId = Number(data.chat_id); const messageId = Number(data.message_id); const title = payload.notification?.title ?? "New message"; const body = payload.notification?.body ?? "Open chat"; if (Number.isFinite(chatId) && Number.isFinite(messageId)) { await showNotificationViaServiceWorker({ chatId, messageId, title, body, }); } } function getFirebaseConfig(): | { apiKey: string; authDomain?: string; projectId: string; storageBucket?: string; messagingSenderId: string; appId: string; } | null { const apiKey = import.meta.env.VITE_FIREBASE_API_KEY?.trim(); const projectId = import.meta.env.VITE_FIREBASE_PROJECT_ID?.trim(); const messagingSenderId = import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID?.trim(); const appId = import.meta.env.VITE_FIREBASE_APP_ID?.trim(); if (!apiKey || !projectId || !messagingSenderId || !appId) { return null; } return { apiKey, authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN?.trim() || undefined, projectId, storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET?.trim() || undefined, messagingSenderId, appId, }; } function normalizeVapidKey(raw: string | undefined): string | null { if (!raw) { return null; } let key = raw.trim(); if (!key) { return null; } // Accept accidental JSON payloads copied from docs/panels. if (key.startsWith("{") && key.endsWith("}")) { try { const parsed = JSON.parse(key) as { vapidKey?: string; publicKey?: string; key?: string }; key = (parsed.vapidKey ?? parsed.publicKey ?? parsed.key ?? "").trim(); } catch { return null; } } // Strip wrapping quotes and whitespace/newlines. key = key.replace(/^['"]|['"]$/g, "").replace(/\s+/g, ""); // Convert classic base64 chars to URL-safe format expected by PushManager/Firebase. key = key.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); if (!/^[A-Za-z0-9\-_]+$/.test(key)) { return null; } return key.length >= 80 ? key : null; }