148 lines
3.9 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|