feat(rmaps): UX parity with rmaps-online — route overlay, ping cooldown, session persistence
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 <noreply@anthropic.com>
This commit is contained in:
parent
496dff3c7f
commit
1eb4e1cb66
|
|
@ -770,7 +770,7 @@ export class CommunitySync extends EventTarget {
|
||||||
* Shapes already forgotten get hard-deleted; others get soft-forgotten.
|
* Shapes already forgotten get hard-deleted; others get soft-forgotten.
|
||||||
*/
|
*/
|
||||||
bulkForget(shapeIds: string[], did: string): void {
|
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) {
|
for (const id of shapeIds) {
|
||||||
const state = this.getShapeVisualState(id);
|
const state = this.getShapeVisualState(id);
|
||||||
changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' });
|
changes.push({ id, before: this.#cloneShapeData(id), action: state === 'forgotten' ? 'delete' : 'forget' });
|
||||||
|
|
|
||||||
|
|
@ -1075,11 +1075,24 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.room = slug;
|
this.room = slug;
|
||||||
this.view = "map";
|
this.view = "map";
|
||||||
this.render();
|
this.render();
|
||||||
this.initMapView();
|
this.initMapView().then(() => this.restoreLastLocation());
|
||||||
this.initRoomSync();
|
this.initRoomSync();
|
||||||
this.initLocalFirstClient();
|
this.initLocalFirstClient();
|
||||||
// Periodically refresh staleness indicators
|
// Periodically refresh staleness indicators
|
||||||
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
|
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() {
|
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.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 inside a folk-shape and not in editing mode, disable map interactions
|
||||||
if (this._parentShape && !this._mapInteractive) {
|
if (this._parentShape && !this._mapInteractive) {
|
||||||
|
|
@ -1250,13 +1264,16 @@ class FolkMapViewer extends HTMLElement {
|
||||||
navigator.serviceWorker.addEventListener("message", (event) => {
|
navigator.serviceWorker.addEventListener("message", (event) => {
|
||||||
if (event.data?.type === "LOCATION_REQUEST") {
|
if (event.data?.type === "LOCATION_REQUEST") {
|
||||||
const reqRoom = event.data.data?.roomSlug;
|
const reqRoom = event.data.data?.roomSlug;
|
||||||
if (reqRoom === this.room && this.sharingLocation) {
|
if (reqRoom === this.room) {
|
||||||
|
this.showPingToast(event.data.data?.fromName || "Someone");
|
||||||
|
if (this.sharingLocation) {
|
||||||
// Already sharing — sync will propagate automatically
|
// Already sharing — sync will propagate automatically
|
||||||
} else if (reqRoom === this.room && this.privacySettings.precision !== "hidden") {
|
} else if (this.privacySettings.precision !== "hidden") {
|
||||||
// Not sharing yet — start sharing in response to ping
|
// Not sharing yet — start sharing in response to ping
|
||||||
this.toggleLocationSharing();
|
this.toggleLocationSharing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1526,6 +1543,11 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.renderNavigationPanel();
|
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", () => {
|
container.querySelector("#sidebar-meeting-btn")?.addEventListener("click", () => {
|
||||||
this.showMeetingModal = true;
|
this.showMeetingModal = true;
|
||||||
this.renderMeetingPointModal();
|
this.renderMeetingPointModal();
|
||||||
|
|
@ -1545,6 +1567,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
|
<div style="display:flex;gap:6px;margin-top:10px;flex-wrap:wrap;">
|
||||||
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
|
<button id="sidebar-meeting-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4CD} Meeting Point</button>
|
||||||
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
|
<button id="sidebar-import-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E5} Import Places</button>
|
||||||
|
<button id="sidebar-ping-all-btn" style="flex:1;padding:6px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;white-space:nowrap;">\u{1F4E2} Ping All</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -1645,6 +1668,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.sharingLocation = false;
|
this.sharingLocation = false;
|
||||||
this.geoTimeoutCount = 0;
|
this.geoTimeoutCount = 0;
|
||||||
this.sync?.clearLocation();
|
this.sync?.clearLocation();
|
||||||
|
try { localStorage.removeItem(`rmaps_sharing_${this.room}`); } catch {}
|
||||||
this.updateShareButton();
|
this.updateShareButton();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1686,6 +1710,12 @@ class FolkMapViewer extends HTMLElement {
|
||||||
};
|
};
|
||||||
this.sync?.updateLocation(loc);
|
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) {
|
if (firstFix && this.map) {
|
||||||
this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 });
|
this.map.flyTo({ center: [pos.coords.longitude, pos.coords.latitude], zoom: 14 });
|
||||||
firstFix = false;
|
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) {
|
private renderPrivacyPanel(container?: HTMLElement) {
|
||||||
const panel = container || this.shadow.getElementById("privacy-panel");
|
const panel = container || this.shadow.getElementById("privacy-panel");
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
|
|
@ -1906,6 +1957,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
|
|
||||||
route.segments.forEach((seg, i) => {
|
route.segments.forEach((seg, i) => {
|
||||||
const sourceId = `route-seg-${i}`;
|
const sourceId = `route-seg-${i}`;
|
||||||
|
const outlineLayerId = `route-layer-outline-${i}`;
|
||||||
const layerId = `route-layer-${i}`;
|
const layerId = `route-layer-${i}`;
|
||||||
this.map.addSource(sourceId, {
|
this.map.addSource(sourceId, {
|
||||||
type: "geojson",
|
type: "geojson",
|
||||||
|
|
@ -1915,6 +1967,14 @@ class FolkMapViewer extends HTMLElement {
|
||||||
geometry: { type: "LineString", coordinates: seg.coordinates },
|
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({
|
this.map.addLayer({
|
||||||
id: layerId,
|
id: layerId,
|
||||||
type: "line",
|
type: "line",
|
||||||
|
|
@ -1935,9 +1995,10 @@ class FolkMapViewer extends HTMLElement {
|
||||||
|
|
||||||
private clearRoute() {
|
private clearRoute() {
|
||||||
if (!this.map) return;
|
if (!this.map) return;
|
||||||
// Remove all route layers/sources
|
// Remove all route layers/sources (including outline layers)
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
try { this.map.removeLayer(`route-layer-${i}`); } catch {}
|
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 {}
|
try { this.map.removeSource(`route-seg-${i}`); } catch {}
|
||||||
}
|
}
|
||||||
this.activeRoute = null;
|
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() {
|
private renderRoutePanel() {
|
||||||
if (!this.activeRoute) return;
|
if (!this.activeRoute) return;
|
||||||
let routePanel = this.shadow.getElementById("route-panel");
|
let routePanel = this.shadow.getElementById("route-panel");
|
||||||
|
|
@ -1982,10 +2053,11 @@ class FolkMapViewer extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<button id="close-route" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;">\u2715</button>
|
<button id="close-route" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:16px;">\u2715</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:16px;font-size:13px;color:var(--rs-text-secondary);margin-bottom:8px;">
|
<div style="display:flex;gap:16px;font-size:13px;color:var(--rs-text-secondary);margin-bottom:4px;">
|
||||||
<span>\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)}</span>
|
<span>\u{1F4CF} ${formatDistance(this.activeRoute.totalDistance)}</span>
|
||||||
<span>\u{23F1} ${formatTime(this.activeRoute.estimatedTime)}</span>
|
<span>\u{23F1} ${formatTime(this.activeRoute.estimatedTime)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="font-size:11px;color:var(--rs-text-muted);margin-bottom:8px;">${this.formatRouteInstructions(this.activeRoute.segments)}</div>
|
||||||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||||||
${this.activeRoute.segments.map(seg => `
|
${this.activeRoute.segments.map(seg => `
|
||||||
<span style="font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid ${segTypeColors[seg.type] || '#666'}40;color:${segTypeColors[seg.type] || '#666'};font-weight:500;">
|
<span style="font-size:10px;padding:2px 8px;border-radius:10px;border:1px solid ${segTypeColors[seg.type] || '#666'}40;color:${segTypeColors[seg.type] || '#666'};font-weight:500;">
|
||||||
|
|
@ -2035,6 +2107,21 @@ class FolkMapViewer extends HTMLElement {
|
||||||
setTimeout(dismiss, 10000);
|
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 = `<span style="font-size:16px">\u{1F4E2}</span><span style="font-size:13px;color:var(--rs-text-primary);">${this.esc(fromName)} pinged for your location</span>`;
|
||||||
|
this.shadow.appendChild(toast);
|
||||||
|
setTimeout(() => { toast.style.opacity = "0"; setTimeout(() => toast.remove(), 200); }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Chat badge ─────────────────────────────────────────────
|
// ─── Chat badge ─────────────────────────────────────────────
|
||||||
|
|
||||||
private updateChatBadge() {
|
private updateChatBadge() {
|
||||||
|
|
@ -2616,6 +2703,7 @@ class FolkMapViewer extends HTMLElement {
|
||||||
|
|
||||||
<!-- Locate-me FAB (visible on all viewports) -->
|
<!-- Locate-me FAB (visible on all viewports) -->
|
||||||
<button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button>
|
<button class="map-locate-fab" id="locate-me-fab" title="Center on my location">\u{1F3AF}</button>
|
||||||
|
<button class="map-locate-fab" id="fit-all-fab" title="Show all participants" style="bottom:80px;">\u{1F465}</button>
|
||||||
|
|
||||||
<!-- Mobile FAB menu -->
|
<!-- Mobile FAB menu -->
|
||||||
<div class="mobile-fab-container" id="mobile-fab-container">
|
<div class="mobile-fab-container" id="mobile-fab-container">
|
||||||
|
|
@ -2772,6 +2860,10 @@ class FolkMapViewer extends HTMLElement {
|
||||||
this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => {
|
this.shadow.getElementById("locate-me-fab")?.addEventListener("click", () => {
|
||||||
this.locateMe();
|
this.locateMe();
|
||||||
});
|
});
|
||||||
|
// Fit-all FAB
|
||||||
|
this.shadow.getElementById("fit-all-fab")?.addEventListener("click", () => {
|
||||||
|
this.fitToParticipants();
|
||||||
|
});
|
||||||
|
|
||||||
// Mobile FAB menu
|
// Mobile FAB menu
|
||||||
this.shadow.getElementById("fab-main")?.addEventListener("click", () => {
|
this.shadow.getElementById("fab-main")?.addEventListener("click", () => {
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,11 @@ class MapImportModal extends HTMLElement {
|
||||||
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;">
|
<div style="background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:14px;padding:24px;max-width:420px;width:90%;max-height:80vh;overflow-y:auto;">
|
||||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
|
||||||
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Preview (${this._places.length} places)</div>
|
<div style="font-size:16px;font-weight:600;color:var(--rs-text-primary);">Preview (${this._places.length} places)</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center;">
|
||||||
|
<button id="i-toggle-all" style="padding:4px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-input-bg);color:var(--rs-text-secondary);cursor:pointer;font-size:11px;">${count === this._places.length ? "Deselect all" : "Select all"}</button>
|
||||||
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
|
<button id="i-close" style="background:none;border:none;color:var(--rs-text-muted);cursor:pointer;font-size:18px;">\u2715</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div style="margin-bottom:12px;">
|
<div style="margin-bottom:12px;">
|
||||||
${this._places.map((p, i) => `
|
${this._places.map((p, i) => `
|
||||||
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);cursor:pointer;font-size:12px;color:var(--rs-text-secondary);">
|
<label style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);cursor:pointer;font-size:12px;color:var(--rs-text-secondary);">
|
||||||
|
|
@ -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", () => {
|
this.querySelector("#i-confirm")?.addEventListener("click", () => {
|
||||||
const selected = this._places.filter(p => p.selected).map(({ name, lat, lng }) => ({ name, lat, lng }));
|
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 }));
|
this.dispatchEvent(new CustomEvent("import-places", { detail: { places: selected }, bubbles: true, composed: true }));
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,9 @@ class MapMeetingModal extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
<div id="m-sr"></div>
|
<div id="m-sr"></div>
|
||||||
`;
|
`;
|
||||||
|
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 () => {
|
this.querySelector("#m-addr-go")?.addEventListener("click", async () => {
|
||||||
const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim();
|
const q = (this.querySelector("#m-addr") as HTMLInputElement).value.trim();
|
||||||
if (!q) return;
|
if (!q) return;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export class MapPushManager {
|
||||||
private registration: ServiceWorkerRegistration | null = null;
|
private registration: ServiceWorkerRegistration | null = null;
|
||||||
private subscription: PushSubscription | null = null;
|
private subscription: PushSubscription | null = null;
|
||||||
private _subscribed = false;
|
private _subscribed = false;
|
||||||
|
private _pingCooldowns: Map<string, number> = new Map();
|
||||||
|
|
||||||
constructor(apiBase: string) {
|
constructor(apiBase: string) {
|
||||||
this.apiBase = apiBase;
|
this.apiBase = apiBase;
|
||||||
|
|
@ -133,6 +134,12 @@ export class MapPushManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
|
async requestLocation(roomSlug: string, participantId: string): Promise<boolean> {
|
||||||
|
// 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 {
|
try {
|
||||||
const res = await fetch(`${this.apiBase}/api/push/request-location`, {
|
const res = await fetch(`${this.apiBase}/api/push/request-location`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,24 @@ class MapShareModal extends HTMLElement {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// 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");
|
const qr = this.querySelector("#s-qr");
|
||||||
if (qr) {
|
if (qr) {
|
||||||
|
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)}`;
|
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(this._url)}`;
|
||||||
qr.innerHTML = `<img src="${qrSrc}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;" onerror="this.parentElement.innerHTML='<div style=\\'font-size:12px;color:var(--rs-text-muted)\\'>QR code unavailable</div>'">`;
|
qr.innerHTML = `<img src="${qrSrc}" alt="QR Code" style="width:200px;height:200px;border-radius:8px;" onerror="this.parentElement.innerHTML='<div style=\\'font-size:12px;color:var(--rs-text-muted)\\'>QR code unavailable</div>'">`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
this.querySelector("#s-close")?.addEventListener("click", () => this.close());
|
this.querySelector("#s-close")?.addEventListener("click", () => this.close());
|
||||||
|
|
|
||||||
|
|
@ -382,8 +382,10 @@ self.addEventListener("push", (event) => {
|
||||||
icon: "/icons/icon-192.png",
|
icon: "/icons/icon-192.png",
|
||||||
badge: "/icons/icon-192.png",
|
badge: "/icons/icon-192.png",
|
||||||
tag: "rmaps-location-request",
|
tag: "rmaps-location-request",
|
||||||
|
requireInteraction: true,
|
||||||
|
vibrate: [200, 100, 200],
|
||||||
data: { ...payload.data, type: "location_request" },
|
data: { ...payload.data, type: "location_request" },
|
||||||
});
|
} as NotificationOptions);
|
||||||
|
|
||||||
// Notify open clients to auto-share if enabled
|
// Notify open clients to auto-share if enabled
|
||||||
const clients = await self.clients.matchAll({ type: "window" });
|
const clients = await self.clients.matchAll({ type: "window" });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue