Merge branch 'dev'
This commit is contained in:
commit
bae633c8df
|
|
@ -1116,11 +1116,40 @@ class FolkMapViewer extends HTMLElement {
|
||||||
});
|
});
|
||||||
|
|
||||||
saveRoomVisit(this.room, this.room);
|
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 ───────────────────────────
|
// ─── State change → update markers ───────────────────────────
|
||||||
|
|
||||||
private onRoomStateChange(state: RoomState) {
|
private onRoomStateChange(state: RoomState) {
|
||||||
|
// Persist to IndexedDB for offline pinging
|
||||||
|
this.persistRoomState(state);
|
||||||
|
|
||||||
if (!this.map || !(window as any).maplibregl) return;
|
if (!this.map || !(window as any).maplibregl) return;
|
||||||
|
|
||||||
const currentIds = new Set<string>();
|
const currentIds = new Set<string>();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* Google Maps GeoJSON parser for rMaps place import.
|
||||||
|
* Ported from rmaps-online/src/lib/googleMapsParser.ts (pure TS, no React deps).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ParsedPlace {
|
||||||
|
name: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
address?: string;
|
||||||
|
category?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
success: boolean;
|
||||||
|
places: ParsedPlace[];
|
||||||
|
error?: string;
|
||||||
|
totalFeatures: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a Google Maps GeoJSON export into a list of places.
|
||||||
|
* Accepts standard GeoJSON FeatureCollection with Point features.
|
||||||
|
*/
|
||||||
|
export function parseGoogleMapsGeoJSON(jsonStr: string): ParseResult {
|
||||||
|
if (jsonStr.length > MAX_FILE_SIZE) {
|
||||||
|
return { success: false, places: [], error: "File too large (max 50 MB)", totalFeatures: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(jsonStr);
|
||||||
|
} catch {
|
||||||
|
return { success: false, places: [], error: "Invalid JSON", totalFeatures: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.type !== "FeatureCollection" || !Array.isArray(data.features)) {
|
||||||
|
return { success: false, places: [], error: "Not a valid GeoJSON FeatureCollection", totalFeatures: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const places: ParsedPlace[] = [];
|
||||||
|
|
||||||
|
for (const feature of data.features) {
|
||||||
|
if (!feature?.geometry || feature.geometry.type !== "Point") continue;
|
||||||
|
|
||||||
|
const coords = feature.geometry.coordinates;
|
||||||
|
if (!Array.isArray(coords) || coords.length < 2) continue;
|
||||||
|
|
||||||
|
const lng = coords[0];
|
||||||
|
const lat = coords[1];
|
||||||
|
|
||||||
|
// Validate coordinates
|
||||||
|
if (typeof lat !== "number" || typeof lng !== "number") continue;
|
||||||
|
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) continue;
|
||||||
|
if (isNaN(lat) || isNaN(lng)) continue;
|
||||||
|
|
||||||
|
// Extract name from various properties formats
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const name =
|
||||||
|
props.name ||
|
||||||
|
props.Name ||
|
||||||
|
props.title ||
|
||||||
|
props.Title ||
|
||||||
|
props["Google Maps URL"]?.split("/place/")?.pop()?.split("/")?.[0]?.replace(/\+/g, " ") ||
|
||||||
|
`Place at ${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||||
|
|
||||||
|
places.push({
|
||||||
|
name: String(name).substring(0, 200),
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
address: props.address || props.Address || props.description || undefined,
|
||||||
|
category: props.category || props.Category || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: places.length > 0,
|
||||||
|
places,
|
||||||
|
totalFeatures: data.features.length,
|
||||||
|
...(places.length === 0 ? { error: "No valid Point features found" } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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`;
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
export class MapPushManager {
|
||||||
private apiBase: string;
|
private apiBase: string;
|
||||||
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
|
private subscription: PushSubscription | null = null;
|
||||||
|
private _subscribed = false;
|
||||||
|
|
||||||
constructor(apiBase: string) {
|
constructor(apiBase: string) {
|
||||||
this.apiBase = apiBase;
|
this.apiBase = apiBase;
|
||||||
|
this._subscribed = localStorage.getItem(STORAGE_KEY) === "true";
|
||||||
|
this.initServiceWorker();
|
||||||
}
|
}
|
||||||
|
|
||||||
get isSupported(): boolean {
|
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> {
|
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,20 @@ routes.post("/api/push/request-location", async (c) => {
|
||||||
return c.json(await res.json(), res.status as any);
|
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) ──
|
// ── Proxy: routing (OSRM + c3nav) ──
|
||||||
routes.post("/api/routing", async (c) => {
|
routes.post("/api/routing", async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const GRAPH3D_HEAD = `<script src="https://cdn.jsdelivr.net/npm/3d-force-graph@1
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online";
|
const TWENTY_API_URL = process.env.TWENTY_API_URL || "https://crm.rspace.online";
|
||||||
const TWENTY_DEFAULT_TOKEN = process.env.TWENTY_API_TOKEN || "";
|
const TWENTY_DEFAULT_TOKEN = (process.env.TWENTY_API_TOKEN && process.env.TWENTY_API_TOKEN !== "disabled") ? process.env.TWENTY_API_TOKEN : "";
|
||||||
|
|
||||||
// Build token map from env vars: TWENTY_TOKEN_COMMONS_HUB -> "commons-hub"
|
// Build token map from env vars: TWENTY_TOKEN_COMMONS_HUB -> "commons-hub"
|
||||||
const twentyTokens = new Map<string, string>();
|
const twentyTokens = new Map<string, string>();
|
||||||
|
|
|
||||||
|
|
@ -1190,7 +1190,7 @@ class FolkWalletViewer extends HTMLElement {
|
||||||
/* ── Top-level tabs ── */
|
/* ── Top-level tabs ── */
|
||||||
.top-tabs {
|
.top-tabs {
|
||||||
display: flex; gap: 0; border-bottom: 2px solid var(--rs-border-subtle);
|
display: flex; gap: 0; border-bottom: 2px solid var(--rs-border-subtle);
|
||||||
margin-bottom: 24px; max-width: 640px; margin-left: auto; margin-right: auto;
|
margin-bottom: 8px; max-width: 640px; margin-left: auto; margin-right: auto;
|
||||||
}
|
}
|
||||||
.top-tab {
|
.top-tab {
|
||||||
padding: 12px 24px; border: none; background: transparent;
|
padding: 12px 24px; border: none; background: transparent;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue