rspace-online/modules/rmaps/components/map-push.ts

148 lines
3.9 KiB
TypeScript

/**
* 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<string | null> {
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<boolean> {
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<boolean> {
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<boolean> {
if (this._subscribed) {
await this.unsubscribe();
return false;
} else {
const ok = await this.subscribe();
return ok;
}
}
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
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;
}
}
}