diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index 0d29e50..6b89935 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -1116,11 +1116,40 @@ class FolkMapViewer extends HTMLElement { }); saveRoomVisit(this.room, this.room); + + // Listen for SW-forwarded location request pushes + if ("serviceWorker" in navigator) { + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "LOCATION_REQUEST") { + const reqRoom = event.data.data?.roomSlug; + if (reqRoom === this.room && this.sharingLocation) { + // Already sharing — sync will propagate automatically + } else if (reqRoom === this.room && !this.privacySettings.ghostMode) { + // Not sharing yet — start sharing in response to ping + this.toggleLocationSharing(); + } + } + }); + } + } + + // ─── Persist room state to SW IndexedDB for offline access ── + + private persistRoomState(state: RoomState) { + if (!this.room || !("serviceWorker" in navigator)) return; + navigator.serviceWorker.controller?.postMessage({ + type: "SAVE_ROOM_STATE", + roomSlug: this.room, + state, + }); } // ─── State change → update markers ─────────────────────────── private onRoomStateChange(state: RoomState) { + // Persist to IndexedDB for offline pinging + this.persistRoomState(state); + if (!this.map || !(window as any).maplibregl) return; const currentIds = new Set(); diff --git a/modules/rmaps/components/map-privacy.ts b/modules/rmaps/components/map-privacy.ts new file mode 100644 index 0000000..49e9581 --- /dev/null +++ b/modules/rmaps/components/map-privacy.ts @@ -0,0 +1,74 @@ +/** + * Privacy utilities for rMaps: location fuzzing, distance calculations, formatting. + */ + +import type { PrecisionLevel } from "./map-sync"; + +/** Noise radius in meters per precision level */ +const PRECISION_RADIUS: Record = { + exact: 0, + building: 50, + area: 500, + approximate: 5000, +}; + +/** + * Add random noise to coordinates based on precision level. + * Returns original coords if precision is "exact". + */ +export function fuzzLocation(lat: number, lng: number, precision: PrecisionLevel): { latitude: number; longitude: number } { + const radius = PRECISION_RADIUS[precision]; + if (radius === 0) return { latitude: lat, longitude: lng }; + + // Random angle and distance within radius + const angle = Math.random() * 2 * Math.PI; + const dist = Math.random() * radius; + + // Approximate meters to degrees + const dLat = (dist * Math.cos(angle)) / 111320; + const dLng = (dist * Math.sin(angle)) / (111320 * Math.cos(lat * (Math.PI / 180))); + + return { + latitude: lat + dLat, + longitude: lng + dLng, + }; +} + +/** + * Haversine distance between two points in meters. + */ +export function haversineDistance(lat1: number, lng1: number, lat2: number, lng2: number): number { + const R = 6371000; // Earth radius in meters + const toRad = (d: number) => d * (Math.PI / 180); + + const dLat = toRad(lat2 - lat1); + const dLng = toRad(lng2 - lng1); + const a = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; +} + +/** + * Format a distance in meters to a human-readable string. + */ +export function formatDistance(meters: number): string { + if (meters < 50) return "nearby"; + if (meters < 1000) return `${Math.round(meters)}m`; + if (meters < 10000) return `${(meters / 1000).toFixed(1)}km`; + return `${Math.round(meters / 1000)}km`; +} + +/** + * Format seconds to a human-readable duration. + */ +export function formatTime(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + const m = Math.floor(seconds / 60); + if (m < 60) return `${m} min`; + const h = Math.floor(m / 60); + const rm = m % 60; + return rm > 0 ? `${h}h ${rm}m` : `${h}h`; +} diff --git a/modules/rmaps/components/map-push.ts b/modules/rmaps/components/map-push.ts index 2e1a065..ea6cba8 100644 --- a/modules/rmaps/components/map-push.ts +++ b/modules/rmaps/components/map-push.ts @@ -1,16 +1,135 @@ /** - * Push notification helpers for "ping friends" in map rooms. + * 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; + 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 { diff --git a/modules/rmaps/mod.ts b/modules/rmaps/mod.ts index f0b16f9..dbb4790 100644 --- a/modules/rmaps/mod.ts +++ b/modules/rmaps/mod.ts @@ -83,6 +83,20 @@ routes.post("/api/push/request-location", async (c) => { return c.json(await res.json(), res.status as any); }); +// ── Proxy: last-known-location (offline ping response) ── +routes.post("/api/push/last-known-location", async (c) => { + const body = await c.req.json(); + try { + const res = await fetch(`${SYNC_SERVER}/push/last-known-location`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) return c.json(await res.json()); + } catch {} + return c.json({ received: true }); +}); + // ── Proxy: routing (OSRM + c3nav) ── routes.post("/api/routing", async (c) => { const body = await c.req.json();