fix(rmaps): commit all pending rmaps changes and missing files

Add map-privacy.ts and updated folk-map-viewer.ts, map-push.ts, mod.ts
that were modified locally but never committed, breaking Docker builds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 20:38:26 -07:00
parent 21a9a0f7e3
commit b0d918274d
4 changed files with 238 additions and 2 deletions

View File

@ -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<string>();

View File

@ -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<PrecisionLevel, number> = {
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`;
}

View File

@ -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<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> {

View File

@ -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();