/** * Push notification manager for rMaps — service worker registration, * VAPID subscription, and location request pinging. */ const STORAGE_KEY = "rmaps_push_subscribed"; export class MapPushManager { private apiBase: string; private registration: ServiceWorkerRegistration | null = null; private subscription: PushSubscription | null = null; private _subscribed = false; constructor(apiBase: string) { this.apiBase = apiBase; this._subscribed = localStorage.getItem(STORAGE_KEY) === "true"; this.initServiceWorker(); } get isSupported(): boolean { return "Notification" in window && "serviceWorker" in navigator && "PushManager" in window; } get isSubscribed(): boolean { return this._subscribed; } private async initServiceWorker() { if (!this.isSupported) return; try { this.registration = await navigator.serviceWorker.ready; // Check existing subscription this.subscription = await this.registration.pushManager.getSubscription(); if (this.subscription) { this._subscribed = true; localStorage.setItem(STORAGE_KEY, "true"); } } catch { // SW not available } } private async getVapidPublicKey(): Promise { try { const res = await fetch(`${this.apiBase}/api/push/vapid-public-key`, { signal: AbortSignal.timeout(5000), }); if (res.ok) { const data = await res.json(); return data.publicKey || data.key || null; } } catch {} return null; } private urlBase64ToUint8Array(base64String: string): Uint8Array { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/"); const rawData = atob(base64); const outputArray = new Uint8Array(rawData.length); for (let i = 0; i < rawData.length; i++) { outputArray[i] = rawData.charCodeAt(i); } return outputArray; } async subscribe(): Promise { if (!this.isSupported || !this.registration) return false; try { // Request notification permission const permission = await Notification.requestPermission(); if (permission !== "granted") return false; // Get VAPID key const vapidKey = await this.getVapidPublicKey(); if (!vapidKey) return false; // Subscribe via PushManager this.subscription = await this.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: this.urlBase64ToUint8Array(vapidKey) as any, }); // Sync with server const res = await fetch(`${this.apiBase}/api/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subscription: this.subscription.toJSON() }), }); if (res.ok) { this._subscribed = true; localStorage.setItem(STORAGE_KEY, "true"); return true; } } catch (err) { console.warn("Push subscribe failed:", err); } return false; } async unsubscribe(): Promise { if (!this.subscription) return true; try { // Notify server await fetch(`${this.apiBase}/api/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subscription: this.subscription.toJSON() }), }).catch(() => {}); // Unsubscribe from PushManager await this.subscription.unsubscribe(); this.subscription = null; this._subscribed = false; localStorage.removeItem(STORAGE_KEY); return true; } catch { return false; } } async toggle(roomSlug: string, participantId: string): Promise { if (this._subscribed) { await this.unsubscribe(); return false; } else { const ok = await this.subscribe(); return ok; } } async requestLocation(roomSlug: string, participantId: string): Promise { try { const res = await fetch(`${this.apiBase}/api/push/request-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roomSlug, participantId }), }); return res.ok; } catch { return false; } } }