From 1eb4e1cb667728d138ca3636d6209bc941ffe6a4 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 18:25:46 -0700 Subject: [PATCH] =?UTF-8?q?feat(rmaps):=20UX=20parity=20with=20rmaps-onlin?= =?UTF-8?q?e=20=E2=80=94=20route=20overlay,=20ping=20cooldown,=20session?= =?UTF-8?q?=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Route double-layer outline, ScaleControl, route instruction summary, Enter key in meeting search, select-all toggle in import preview, fitToParticipants FAB. Phase 2: Ping All sidebar button, push vibrate+requireInteraction, 30s ping cooldown, bundled QR code (qrcode package with external API fallback), ping toast with vibration. Phase 3: Location persistence across sessions (<30min restore), auto-start sharing preference. Also fix pre-existing TS error in community-sync.ts (bulkForget changes array typing). Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 2 +- modules/rmaps/components/folk-map-viewer.ts | 108 ++++++++++++++++-- modules/rmaps/components/map-import-modal.ts | 12 +- modules/rmaps/components/map-meeting-modal.ts | 3 + modules/rmaps/components/map-push.ts | 7 ++ modules/rmaps/components/map-share-modal.ts | 18 ++- website/sw.ts | 4 +- 7 files changed, 140 insertions(+), 14 deletions(-) diff --git a/lib/community-sync.ts b/lib/community-sync.ts index b7f213c..f461475 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -770,7 +770,7 @@ export class CommunitySync extends EventTarget { * Shapes already forgotten get hard-deleted; others get soft-forgotten. */ bulkForget(shapeIds: string[], did: string): void { - const changes: Array<{ id: string; before: unknown; action: 'forget' | 'delete' }> = []; + const changes: Array<{ id: string; before: ShapeData | null; action: 'forget' | 'delete' }> = []; for (const id of shapeIds) { const state = this.getShapeVisualState(id); changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' }); diff --git a/modules/rmaps/components/folk-map-viewer.ts b/modules/rmaps/components/folk-map-viewer.ts index dc4bc57..868a8ed 100644 --- a/modules/rmaps/components/folk-map-viewer.ts +++ b/modules/rmaps/components/folk-map-viewer.ts @@ -1075,11 +1075,24 @@ class FolkMapViewer extends HTMLElement { this.room = slug; this.view = "map"; this.render(); - this.initMapView(); + this.initMapView().then(() => this.restoreLastLocation()); this.initRoomSync(); this.initLocalFirstClient(); // Periodically refresh staleness indicators this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000); + // Auto-start sharing if user had it enabled previously + if (localStorage.getItem(`rmaps_sharing_${slug}`) === "true") { + setTimeout(() => this.toggleLocationSharing(), 500); + } + } + + private restoreLastLocation() { + try { + const saved = JSON.parse(localStorage.getItem(`rmaps_loc_${this.room}`) || "null"); + if (saved && this.map && Date.now() - saved.ts < 30 * 60 * 1000) { + this.map.flyTo({ center: [saved.lng, saved.lat], zoom: 13 }); + } + } catch {} } private async initLocalFirstClient() { @@ -1173,6 +1186,7 @@ class FolkMapViewer extends HTMLElement { }); this.map.addControl(new (window as any).maplibregl.NavigationControl(), "top-right"); + this.map.addControl(new (window as any).maplibregl.ScaleControl(), "bottom-left"); // If inside a folk-shape and not in editing mode, disable map interactions if (this._parentShape && !this._mapInteractive) { @@ -1250,11 +1264,14 @@ class FolkMapViewer extends HTMLElement { 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.precision !== "hidden") { - // Not sharing yet — start sharing in response to ping - this.toggleLocationSharing(); + if (reqRoom === this.room) { + this.showPingToast(event.data.data?.fromName || "Someone"); + if (this.sharingLocation) { + // Already sharing — sync will propagate automatically + } else if (this.privacySettings.precision !== "hidden") { + // Not sharing yet — start sharing in response to ping + this.toggleLocationSharing(); + } } } }); @@ -1526,6 +1543,11 @@ class FolkMapViewer extends HTMLElement { this.renderNavigationPanel(); }); }); + container.querySelector("#sidebar-ping-all-btn")?.addEventListener("click", () => { + this.pushManager?.requestLocation(this.room, "all"); + const btn = container.querySelector("#sidebar-ping-all-btn"); + if (btn) { btn.textContent = "\u2713 Pinged!"; setTimeout(() => { btn.textContent = "\u{1F4E2} Ping All"; }, 2000); } + }); container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => { this.showMeetingModal = true; this.renderMeetingPointModal(); @@ -1545,6 +1567,7 @@ class FolkMapViewer extends HTMLElement {
+
`; @@ -1645,6 +1668,7 @@ class FolkMapViewer extends HTMLElement { this.sharingLocation = false; this.geoTimeoutCount = 0; this.sync?.clearLocation(); + try { localStorage.removeItem(`rmaps_sharing_${this.room}`); } catch {} this.updateShareButton(); return; } @@ -1686,6 +1710,12 @@ class FolkMapViewer extends HTMLElement { }; this.sync?.updateLocation(loc); + // Persist location + auto-share preference for session restore + try { + localStorage.setItem(`rmaps_loc_${this.room}`, JSON.stringify({ lat: pos.coords.latitude, lng: pos.coords.longitude, ts: Date.now() })); + localStorage.setItem(`rmaps_sharing_${this.room}`, "true"); + } catch {} + if (firstFix && this.map) { this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 }); firstFix = false; @@ -1802,6 +1832,27 @@ class FolkMapViewer extends HTMLElement { } } + private fitToParticipants() { + if (!this.map || !(window as any).maplibregl) return; + const state = this.sync?.getState(); + if (!state) return; + const bounds = new (window as any).maplibregl.LngLatBounds(); + let count = 0; + for (const p of Object.values(state.participants)) { + if (p.location) { + bounds.extend([p.location.longitude, p.location.latitude]); + count++; + } + } + if (count < 1) return; + if (count === 1) { + const first = Object.values(state.participants).find(p => p.location)!; + this.map.flyTo({ center: [first.location!.longitude, first.location!.latitude], zoom: 14 }); + } else { + this.map.fitBounds(bounds, { padding: 60, maxZoom: 16 }); + } + } + private renderPrivacyPanel(container?: HTMLElement) { const panel = container || this.shadow.getElementById("privacy-panel"); if (!panel) return; @@ -1906,6 +1957,7 @@ class FolkMapViewer extends HTMLElement { route.segments.forEach((seg, i) => { const sourceId = `route-seg-${i}`; + const outlineLayerId = `route-layer-outline-${i}`; const layerId = `route-layer-${i}`; this.map.addSource(sourceId, { type: "geojson", @@ -1915,6 +1967,14 @@ class FolkMapViewer extends HTMLElement { geometry: { type: "LineString", coordinates: seg.coordinates }, }, }); + // Outline layer (dark stroke behind colored line) + this.map.addLayer({ + id: outlineLayerId, + type: "line", + source: sourceId, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { "line-color": "#1e293b", "line-width": 8, "line-opacity": 0.6 }, + }); this.map.addLayer({ id: layerId, type: "line", @@ -1935,9 +1995,10 @@ class FolkMapViewer extends HTMLElement { private clearRoute() { if (!this.map) return; - // Remove all route layers/sources + // Remove all route layers/sources (including outline layers) for (let i = 0; i < 10; i++) { try { this.map.removeLayer(`route-layer-${i}`); } catch {} + try { this.map.removeLayer(`route-layer-outline-${i}`); } catch {} try { this.map.removeSource(`route-seg-${i}`); } catch {} } this.activeRoute = null; @@ -1958,6 +2019,16 @@ class FolkMapViewer extends HTMLElement { } } + private formatRouteInstructions(segments: any[]): string { + const parts: string[] = []; + for (const seg of segments) { + const label = seg.type === "indoor" ? "indoors" : seg.type === "transition" ? "transition" : "outdoors"; + parts.push(`${formatDistance(seg.distance)} ${label}`); + } + if (parts.length <= 1) return parts[0] ? `Walk ${parts[0]}` : ""; + return `Walk ${parts.join(", then ")}`; + } + private renderRoutePanel() { if (!this.activeRoute) return; let routePanel = this.shadow.getElementById("route-panel"); @@ -1982,10 +2053,11 @@ class FolkMapViewer extends HTMLElement { -
+
\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)} \u{23F1} ${formatTime(this.activeRoute.estimatedTime)}
+
${this.formatRouteInstructions(this.activeRoute.segments)}
${this.activeRoute.segments.map(seg => ` @@ -2035,6 +2107,21 @@ class FolkMapViewer extends HTMLElement { setTimeout(dismiss, 10000); } + private showPingToast(fromName: string) { + // Vibrate if available + if ("vibrate" in navigator) navigator.vibrate([200, 100, 200]); + const toast = document.createElement("div"); + toast.style.cssText = ` + position:fixed;top:16px;left:50%;transform:translateX(-50%);z-index:100; + background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong); + border-radius:12px;padding:10px 16px;box-shadow:0 8px 24px rgba(0,0,0,0.4); + display:flex;align-items:center;gap:8px;max-width:300px; + `; + toast.innerHTML = `\u{1F4E2}${this.esc(fromName)} pinged for your location`; + this.shadow.appendChild(toast); + setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 4000); + } + // ─── Chat badge ───────────────────────────────────────────── private updateChatBadge() { @@ -2616,6 +2703,7 @@ class FolkMapViewer extends HTMLElement { +
@@ -2772,6 +2860,10 @@ class FolkMapViewer extends HTMLElement { this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => { this.locateMe(); }); + // Fit-all FAB + this.shadow.getElementById("fit-all-fab")?.addEventListener("click", () => { + this.fitToParticipants(); + }); // Mobile FAB menu this.shadow.getElementById("fab-main")?.addEventListener("click", () => { diff --git a/modules/rmaps/components/map-import-modal.ts b/modules/rmaps/components/map-import-modal.ts index e443500..5552c97 100644 --- a/modules/rmaps/components/map-import-modal.ts +++ b/modules/rmaps/components/map-import-modal.ts @@ -132,7 +132,10 @@ class MapImportModal extends HTMLElement {
Preview (${this._places.length} places)
- +
+ + +
${this._places.map((p, i) => ` @@ -157,6 +160,13 @@ class MapImportModal extends HTMLElement { }); }); + this.querySelector("#i-toggle-all")?.addEventListener("click", () => { + const allSelected = this._places.every(p => p.selected); + this._places.forEach(p => p.selected = !allSelected); + this._step = "preview"; + this.render(); + }); + this.querySelector("#i-confirm")?.addEventListener("click", () => { const selected = this._places.filter(p => p.selected).map(({ name, lat, lng }) => ({ name, lat, lng })); this.dispatchEvent(new CustomEvent("import-places", { detail: { places: selected }, bubbles: true, composed: true })); diff --git a/modules/rmaps/components/map-meeting-modal.ts b/modules/rmaps/components/map-meeting-modal.ts index 4d6145f..ff9a986 100644 --- a/modules/rmaps/components/map-meeting-modal.ts +++ b/modules/rmaps/components/map-meeting-modal.ts @@ -103,6 +103,9 @@ class MapMeetingModal extends HTMLElement {
`; + this.querySelector("#m-addr")?.addEventListener("keydown", (e) => { + if ((e as KeyboardEvent).key === "Enter") { (this.querySelector("#m-addr-go") as HTMLElement)?.click(); } + }); this.querySelector("#m-addr-go")?.addEventListener("click", async () => { const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim(); if (!q) return; diff --git a/modules/rmaps/components/map-push.ts b/modules/rmaps/components/map-push.ts index ea6cba8..4941663 100644 --- a/modules/rmaps/components/map-push.ts +++ b/modules/rmaps/components/map-push.ts @@ -10,6 +10,7 @@ export class MapPushManager { private registration: ServiceWorkerRegistration | null = null; private subscription: PushSubscription | null = null; private _subscribed = false; + private _pingCooldowns: Map = new Map(); constructor(apiBase: string) { this.apiBase = apiBase; @@ -133,6 +134,12 @@ export class MapPushManager { } async requestLocation(roomSlug: string, participantId: string): Promise { + // 30s cooldown per participant + const cooldownKey = `${roomSlug}:${participantId}`; + const lastPing = this._pingCooldowns.get(cooldownKey); + if (lastPing && Date.now() - lastPing < 30000) return false; + this._pingCooldowns.set(cooldownKey, Date.now()); + try { const res = await fetch(`${this.apiBase}/api/push/request-location`, { method: "POST", diff --git a/modules/rmaps/components/map-share-modal.ts b/modules/rmaps/components/map-share-modal.ts index d9eb7ec..ad9102e 100644 --- a/modules/rmaps/components/map-share-modal.ts +++ b/modules/rmaps/components/map-share-modal.ts @@ -42,11 +42,23 @@ class MapShareModal extends HTMLElement {
`; - // QR code via public API (no Node.js dependency) + // QR code via bundled qrcode package (canvas), with external API fallback const qr = this.querySelector("#s-qr"); if (qr) { - const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`; - qr.innerHTML = `QR Code`; + try { + const QRCode = (await import("qrcode")).default; + const canvas = document.createElement("canvas"); + canvas.width = 200; + canvas.height = 200; + canvas.style.cssText = "width:200px;height:200px;border-radius:8px;"; + await QRCode.toCanvas(canvas, this._url, { width: 200, margin: 2 }); + qr.innerHTML = ""; + qr.appendChild(canvas); + } catch { + // Fallback to external API + const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`; + qr.innerHTML = `QR Code`; + } } // Events diff --git a/website/sw.ts b/website/sw.ts index d47d798..af4375f 100644 --- a/website/sw.ts +++ b/website/sw.ts @@ -382,8 +382,10 @@ self.addEventListener("push", (event) => { icon: "/icons/icon-192.png", badge: "/icons/icon-192.png", tag: "rmaps-location-request", + requireInteraction: true, + vibrate: [200, 100, 200], data: { ...payload.data, type: "location_request" }, - }); + } as NotificationOptions); // Notify open clients to auto-share if enabled const clients = await self.clients.matchAll({ type: "window" });