-
🌎
-
Map Room: ${this.esc(this.room)}
-
Connect the MapLibre GL library to display the interactive map.
-
WebSocket sync: ${this.syncStatus}
+
-
+
+
@@ -965,27 +1477,18 @@ class FolkMapViewer extends HTMLElement {
this.shadow.querySelectorAll("[data-back]").forEach((el) => {
el.addEventListener("click", () => {
+ this.leaveRoom();
this.view = "lobby";
this.loadStats();
});
});
this.shadow.getElementById("share-location")?.addEventListener("click", () => {
- if ("geolocation" in navigator) {
- navigator.geolocation.getCurrentPosition(
- (pos) => {
- const btn = this.shadow.getElementById("share-location");
- if (btn) {
- btn.textContent = `Location: ${pos.coords.latitude.toFixed(4)}, ${pos.coords.longitude.toFixed(4)}`;
- btn.classList.add("sharing");
- }
- },
- () => {
- this.error = "Location access denied";
- this.render();
- }
- );
- }
+ this.toggleLocationSharing();
+ });
+
+ this.shadow.getElementById("drop-waypoint")?.addEventListener("click", () => {
+ this.dropWaypoint();
});
const copyUrl = this.shadow.getElementById("copy-url") || this.shadow.getElementById("copy-link");
@@ -996,6 +1499,17 @@ class FolkMapViewer extends HTMLElement {
setTimeout(() => { if (copyUrl) copyUrl.textContent = "Copy"; }, 2000);
});
});
+
+ // Ping buttons on history cards
+ this.shadow.querySelectorAll("[data-ping-room]").forEach((btn) => {
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ const slug = (btn as HTMLElement).dataset.pingRoom!;
+ this.pushManager?.requestLocation(slug, "all");
+ (btn as HTMLElement).textContent = "\u2713";
+ setTimeout(() => { (btn as HTMLElement).textContent = "\u{1F514}"; }, 2000);
+ });
+ });
}
private esc(s: string): string {
diff --git a/modules/rmaps/components/map-push.ts b/modules/rmaps/components/map-push.ts
new file mode 100644
index 0000000..2e1a065
--- /dev/null
+++ b/modules/rmaps/components/map-push.ts
@@ -0,0 +1,28 @@
+/**
+ * Push notification helpers for "ping friends" in map rooms.
+ */
+
+export class MapPushManager {
+ private apiBase: string;
+
+ constructor(apiBase: string) {
+ this.apiBase = apiBase;
+ }
+
+ get isSupported(): boolean {
+ return "Notification" in window && "serviceWorker" in navigator;
+ }
+
+ async requestLocation(roomSlug: string, participantId: string): Promise
{
+ try {
+ const res = await fetch(`${this.apiBase}/api/push/request-location`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ roomSlug, participantId }),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ }
+ }
+}
diff --git a/modules/rmaps/components/map-room-history.ts b/modules/rmaps/components/map-room-history.ts
new file mode 100644
index 0000000..ac41ad9
--- /dev/null
+++ b/modules/rmaps/components/map-room-history.ts
@@ -0,0 +1,54 @@
+/**
+ * Room history — localStorage-backed list of recently visited map rooms.
+ */
+
+export interface RoomHistoryEntry {
+ slug: string;
+ name: string;
+ lastVisited: string;
+ thumbnail?: string;
+ center?: [number, number]; // [lng, lat]
+ zoom?: number;
+}
+
+const STORAGE_KEY = "rmaps_room_history";
+const MAX_ENTRIES = 20;
+
+export function loadRoomHistory(): RoomHistoryEntry[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (raw) return JSON.parse(raw) as RoomHistoryEntry[];
+ } catch {}
+ return [];
+}
+
+function save(entries: RoomHistoryEntry[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entries.slice(0, MAX_ENTRIES)));
+ } catch {}
+}
+
+export function saveRoomVisit(slug: string, name?: string, center?: [number, number], zoom?: number): void {
+ const entries = loadRoomHistory().filter((e) => e.slug !== slug);
+ entries.unshift({
+ slug,
+ name: name || slug,
+ lastVisited: new Date().toISOString(),
+ center,
+ zoom,
+ });
+ save(entries);
+}
+
+export function updateRoomThumbnail(slug: string, thumbnail: string): void {
+ const entries = loadRoomHistory();
+ const entry = entries.find((e) => e.slug === slug);
+ if (entry) {
+ entry.thumbnail = thumbnail;
+ save(entries);
+ }
+}
+
+export function removeRoomFromHistory(slug: string): void {
+ save(loadRoomHistory().filter((e) => e.slug !== slug));
+}
diff --git a/modules/rmaps/components/map-sync.ts b/modules/rmaps/components/map-sync.ts
new file mode 100644
index 0000000..7294bf9
--- /dev/null
+++ b/modules/rmaps/components/map-sync.ts
@@ -0,0 +1,290 @@
+/**
+ * WebSocket-based room sync for rMaps.
+ * Ported from rmaps-online/src/lib/sync.ts (simplified — no @/types dependency).
+ */
+
+export interface RoomState {
+ id: string;
+ slug: string;
+ name: string;
+ createdAt: string;
+ participants: Record;
+ waypoints: WaypointState[];
+}
+
+export interface ParticipantState {
+ id: string;
+ name: string;
+ emoji: string;
+ color: string;
+ joinedAt: string;
+ lastSeen: string;
+ status: string;
+ location?: LocationState;
+}
+
+export interface LocationState {
+ latitude: number;
+ longitude: number;
+ accuracy: number;
+ altitude?: number;
+ heading?: number;
+ speed?: number;
+ timestamp: string;
+ source: string;
+ indoor?: { level: number; x: number; y: number; spaceName?: string };
+}
+
+export interface WaypointState {
+ id: string;
+ name: string;
+ emoji?: string;
+ latitude: number;
+ longitude: number;
+ indoor?: { level: number; x: number; y: number };
+ createdBy: string;
+ createdAt: string;
+ type: string;
+}
+
+export type SyncMessage =
+ | { type: "join"; participant: ParticipantState }
+ | { type: "leave"; participantId: string }
+ | { type: "location"; participantId: string; location: LocationState }
+ | { type: "status"; participantId: string; status: string }
+ | { type: "waypoint_add"; waypoint: WaypointState }
+ | { type: "waypoint_remove"; waypointId: string }
+ | { type: "full_state"; state: RoomState }
+ | { type: "request_state" }
+ | { type: "request_location"; manual?: boolean; callerName?: string };
+
+type SyncCallback = (state: RoomState) => void;
+type ConnectionCallback = (connected: boolean) => void;
+type LocationRequestCallback = (manual?: boolean, callerName?: string) => void;
+
+export function isValidLocation(location: LocationState | undefined): boolean {
+ if (!location) return false;
+ const { latitude, longitude } = location;
+ if (latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) return false;
+ if (latitude === 0 && longitude === 0) return false;
+ if (isNaN(latitude) || isNaN(longitude)) return false;
+ return true;
+}
+
+export class RoomSync {
+ private slug: string;
+ private state: RoomState;
+ private ws: WebSocket | null = null;
+ private reconnectTimer: ReturnType | null = null;
+ private onStateChange: SyncCallback;
+ private onConnectionChange: ConnectionCallback;
+ private onLocationRequest: LocationRequestCallback | null = null;
+ private participantId: string;
+ private currentParticipant: ParticipantState | null = null;
+
+ constructor(
+ slug: string,
+ participantId: string,
+ onStateChange: SyncCallback,
+ onConnectionChange: ConnectionCallback,
+ onLocationRequest?: LocationRequestCallback,
+ ) {
+ this.slug = slug;
+ this.participantId = participantId;
+ this.onStateChange = onStateChange;
+ this.onConnectionChange = onConnectionChange;
+ this.onLocationRequest = onLocationRequest || null;
+ this.state = this.loadState() || this.createInitialState();
+ }
+
+ private createInitialState(): RoomState {
+ return {
+ id: crypto.randomUUID(),
+ slug: this.slug,
+ name: this.slug,
+ createdAt: new Date().toISOString(),
+ participants: {},
+ waypoints: [],
+ };
+ }
+
+ private loadState(): RoomState | null {
+ try {
+ const stored = localStorage.getItem(`rmaps_room_${this.slug}`);
+ if (stored) {
+ const state = JSON.parse(stored) as RoomState;
+ return this.cleanupStaleParticipants(state);
+ }
+ } catch (e) {
+ console.warn("Failed to load room state:", e);
+ }
+ return null;
+ }
+
+ private cleanupStaleParticipants(state: RoomState): RoomState {
+ const STALE_MS = 15 * 60 * 1000;
+ const now = Date.now();
+ const cleaned: Record = {};
+ for (const [id, p] of Object.entries(state.participants)) {
+ const isStale = now - new Date(p.lastSeen).getTime() > STALE_MS;
+ if (id === this.participantId || !isStale) {
+ if (p.location && !isValidLocation(p.location)) delete p.location;
+ cleaned[id] = p;
+ }
+ }
+ return { ...state, participants: cleaned };
+ }
+
+ private saveState(): void {
+ try {
+ localStorage.setItem(`rmaps_room_${this.slug}`, JSON.stringify(this.state));
+ } catch {}
+ }
+
+ private notifyStateChange(): void {
+ this.saveState();
+ this.onStateChange({ ...this.state });
+ }
+
+ connect(syncUrl?: string): void {
+ if (!syncUrl) {
+ this.onConnectionChange(true);
+ return;
+ }
+ try {
+ this.ws = new WebSocket(`${syncUrl}/room/${this.slug}`);
+ this.ws.onopen = () => {
+ this.onConnectionChange(true);
+ if (this.currentParticipant) {
+ this.send({ type: "join", participant: this.currentParticipant });
+ }
+ };
+ this.ws.onmessage = (event) => {
+ try {
+ this.handleMessage(JSON.parse(event.data));
+ } catch {}
+ };
+ this.ws.onclose = () => {
+ this.onConnectionChange(false);
+ this.scheduleReconnect(syncUrl);
+ };
+ this.ws.onerror = () => {};
+ } catch {
+ this.onConnectionChange(false);
+ }
+ }
+
+ private scheduleReconnect(syncUrl: string): void {
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ this.reconnectTimer = setTimeout(() => this.connect(syncUrl), 5000);
+ }
+
+ private send(message: SyncMessage): void {
+ if (this.ws?.readyState === WebSocket.OPEN) {
+ this.ws.send(JSON.stringify(message));
+ }
+ }
+
+ private handleMessage(message: SyncMessage): void {
+ switch (message.type) {
+ case "full_state": {
+ const myP = this.state.participants[this.participantId];
+ this.state = message.state;
+ if (myP) this.state.participants[this.participantId] = myP;
+ break;
+ }
+ case "join":
+ this.state.participants[message.participant.id] = message.participant;
+ break;
+ case "leave":
+ delete this.state.participants[message.participantId];
+ break;
+ case "location":
+ if (this.state.participants[message.participantId]) {
+ if (message.location && isValidLocation(message.location)) {
+ this.state.participants[message.participantId].location = message.location;
+ this.state.participants[message.participantId].lastSeen = new Date().toISOString();
+ } else if (message.location === null) {
+ delete this.state.participants[message.participantId].location;
+ this.state.participants[message.participantId].lastSeen = new Date().toISOString();
+ }
+ }
+ break;
+ case "status":
+ if (this.state.participants[message.participantId]) {
+ this.state.participants[message.participantId].status = message.status;
+ this.state.participants[message.participantId].lastSeen = new Date().toISOString();
+ }
+ break;
+ case "waypoint_add":
+ this.state.waypoints.push(message.waypoint);
+ break;
+ case "waypoint_remove":
+ this.state.waypoints = this.state.waypoints.filter((w) => w.id !== message.waypointId);
+ break;
+ case "request_location":
+ if (this.onLocationRequest) this.onLocationRequest(message.manual, message.callerName);
+ return;
+ }
+ this.notifyStateChange();
+ }
+
+ join(participant: ParticipantState): void {
+ this.currentParticipant = participant;
+ this.state.participants[participant.id] = participant;
+ this.send({ type: "join", participant });
+ this.notifyStateChange();
+ }
+
+ leave(): void {
+ delete this.state.participants[this.participantId];
+ this.send({ type: "leave", participantId: this.participantId });
+ this.notifyStateChange();
+ if (this.ws) { this.ws.close(); this.ws = null; }
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
+ }
+
+ updateLocation(location: LocationState): void {
+ if (!isValidLocation(location)) return;
+ if (this.state.participants[this.participantId]) {
+ this.state.participants[this.participantId].location = location;
+ this.state.participants[this.participantId].lastSeen = new Date().toISOString();
+ this.send({ type: "location", participantId: this.participantId, location });
+ this.notifyStateChange();
+ }
+ }
+
+ clearLocation(): void {
+ if (this.state.participants[this.participantId]) {
+ delete this.state.participants[this.participantId].location;
+ this.state.participants[this.participantId].lastSeen = new Date().toISOString();
+ this.send({ type: "location", participantId: this.participantId, location: null as any });
+ this.notifyStateChange();
+ }
+ }
+
+ updateStatus(status: string): void {
+ if (this.state.participants[this.participantId]) {
+ this.state.participants[this.participantId].status = status;
+ this.state.participants[this.participantId].lastSeen = new Date().toISOString();
+ this.send({ type: "status", participantId: this.participantId, status });
+ this.notifyStateChange();
+ }
+ }
+
+ addWaypoint(waypoint: WaypointState): void {
+ this.state.waypoints.push(waypoint);
+ this.send({ type: "waypoint_add", waypoint });
+ this.notifyStateChange();
+ }
+
+ removeWaypoint(waypointId: string): void {
+ this.state.waypoints = this.state.waypoints.filter((w) => w.id !== waypointId);
+ this.send({ type: "waypoint_remove", waypointId });
+ this.notifyStateChange();
+ }
+
+ getState(): RoomState {
+ return { ...this.state };
+ }
+}