Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-12 22:42:37 -07:00
commit e1af7c785b
3 changed files with 1071 additions and 26 deletions

File diff suppressed because it is too large Load Diff

View File

@ -55,7 +55,8 @@ const LIGHT_STYLE = {
};
const PARTICIPANT_COLORS = ["#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6", "#ec4899", "#14b8a6", "#f97316"];
const EMOJIS = ["\u{1F9ED}", "\u{1F30D}", "\u{1F680}", "\u{1F308}", "\u{2B50}", "\u{1F525}", "\u{1F33F}", "\u{1F30A}", "\u{26A1}", "\u{1F48E}"];
const EMOJIS = ["\u{1F600}", "\u{1F60E}", "\u{1F913}", "\u{1F973}", "\u{1F98A}", "\u{1F431}", "\u{1F436}", "\u{1F984}", "\u{1F31F}", "\u{1F525}", "\u{1F49C}", "\u{1F3AE}"];
const STALE_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
class FolkMapViewer extends HTMLElement {
private shadow: ShadowRoot;
@ -104,6 +105,8 @@ class FolkMapViewer extends HTMLElement {
private showShareModal = false;
private showMeetingModal = false;
private showImportModal = false;
private showEmojiPicker = false;
private stalenessTimer: ReturnType<typeof setInterval> | null = null;
private selectedParticipant: string | null = null;
private selectedWaypoint: string | null = null;
private activeRoute: { segments: any[]; totalDistance: number; estimatedTime: number; destination: string } | null = null;
@ -163,6 +166,7 @@ class FolkMapViewer extends HTMLElement {
this._themeObserver.disconnect();
this._themeObserver = null;
}
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
}
// ─── User profile ────────────────────────────────────────────
@ -183,6 +187,37 @@ class FolkMapViewer extends HTMLElement {
this.userColor = PARTICIPANT_COLORS[Math.floor(Math.random() * PARTICIPANT_COLORS.length)];
}
private changeEmoji(emoji: string) {
this.userEmoji = emoji;
localStorage.setItem("rmaps_user", JSON.stringify({
id: this.participantId,
name: this.userName,
emoji: this.userEmoji,
color: this.userColor,
}));
// Update sync state so other participants see the change
if (this.sync) {
const state = this.sync.getState();
const me = state.participants[this.participantId];
if (me) {
me.emoji = emoji;
// Re-join with updated emoji
this.sync.join({ ...me, emoji });
}
}
// Update own marker if present
const myMarker = this.participantMarkers.get(this.participantId);
if (myMarker) {
const el = myMarker.getElement?.();
if (el) {
const emojiSpan = el.querySelector(".marker-emoji");
if (emojiSpan) emojiSpan.textContent = emoji;
}
}
this.showEmojiPicker = false;
this.updateEmojiButton();
}
private ensureUserProfile(): boolean {
if (this.userName) return true;
// Use EncryptID username if authenticated
@ -977,6 +1012,8 @@ class FolkMapViewer extends HTMLElement {
this.render();
this.initMapView();
this.initRoomSync();
// Periodically refresh staleness indicators
this.stalenessTimer = setInterval(() => this.refreshStaleness(), 15000);
}
private createRoom() {
@ -1011,6 +1048,7 @@ class FolkMapViewer extends HTMLElement {
this._themeObserver = null;
}
if (this.thumbnailTimer) clearTimeout(this.thumbnailTimer);
if (this.stalenessTimer) { clearInterval(this.stalenessTimer); this.stalenessTimer = null; }
}
// ─── MapLibre GL ─────────────────────────────────────────────
@ -1155,8 +1193,37 @@ class FolkMapViewer extends HTMLElement {
currentIds.add(id);
if (p.location) {
const lngLat: [number, number] = [p.location.longitude, p.location.latitude];
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
const isStale = ageMs > STALE_THRESHOLD_MS;
if (this.participantMarkers.has(id)) {
this.participantMarkers.get(id).setLngLat(lngLat);
const marker = this.participantMarkers.get(id);
marker.setLngLat(lngLat);
// Update staleness + heading on existing marker
const el = marker.getElement?.();
if (el) {
el.style.opacity = isStale ? "0.5" : "1";
if (isStale) {
el.style.borderColor = "#6b7280";
} else {
el.style.borderColor = p.color;
}
// Update heading arrow
const arrow = el.querySelector(".heading-arrow") as HTMLElement | null;
if (p.location.heading !== undefined && p.location.heading !== null) {
if (arrow) {
arrow.style.transform = `translateX(-50%) rotate(${p.location.heading}deg)`;
arrow.style.display = "block";
arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color;
}
} else if (arrow) {
arrow.style.display = "none";
}
// Update tooltip with age
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
}
} else {
const dark = this.isDarkTheme();
const markerBg = dark ? '#1a1a2e' : '#fafaf7';
@ -1165,13 +1232,38 @@ class FolkMapViewer extends HTMLElement {
el.className = "participant-marker";
el.style.cssText = `
width: 36px; height: 36px; border-radius: 50%;
border: 3px solid ${p.color}; background: ${markerBg};
border: 3px solid ${isStale ? "#6b7280" : p.color}; background: ${markerBg};
display: flex; align-items: center; justify-content: center;
font-size: 18px; cursor: pointer; position: relative;
box-shadow: 0 0 8px ${p.color}60;
opacity: ${isStale ? "0.5" : "1"};
transition: opacity 0.3s;
`;
el.textContent = p.emoji;
el.title = p.name;
// Emoji span with class for later updates
const emojiSpan = document.createElement("span");
emojiSpan.className = "marker-emoji";
emojiSpan.textContent = p.emoji;
el.appendChild(emojiSpan);
// Staleness tooltip
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
// Heading arrow (CSS triangle)
const arrow = document.createElement("div");
arrow.className = "heading-arrow";
arrow.style.cssText = `
position: absolute; top: -6px; left: 50%;
width: 0; height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 8px solid ${p.color};
transform: translateX(-50%)${p.location.heading !== undefined ? ` rotate(${p.location.heading}deg)` : ""};
display: ${p.location.heading !== undefined ? "block" : "none"};
`;
el.appendChild(arrow);
// Name label below
const label = document.createElement("div");
@ -1266,16 +1358,25 @@ class FolkMapViewer extends HTMLElement {
distLabel = formatDistance(haversineDistance(myLoc.latitude, myLoc.longitude, p.location.latitude, p.location.longitude));
}
const statusColor = statusColors[p.status] || "#64748b";
// Staleness info
let ageLabel = "";
let isStale = false;
if (p.location) {
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
isStale = ageMs > STALE_THRESHOLD_MS;
const ageSec = Math.floor(ageMs / 1000);
ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
}
return `
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);">
<div class="participant-entry" style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--rs-border);${isStale ? "opacity:0.6;" : ""}">
<div style="position:relative;">
<span style="font-size:18px">${this.esc(p.emoji)}</span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${statusColor};border:2px solid var(--rs-bg-surface);"></span>
<span style="position:absolute;bottom:-2px;right:-2px;width:8px;height:8px;border-radius:50%;background:${isStale ? "#6b7280" : statusColor};border:2px solid var(--rs-bg-surface);"></span>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:${p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:13px;font-weight:600;color:${isStale ? "#6b7280" : p.color};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${this.esc(p.name)}</div>
<div style="font-size:10px;color:var(--rs-text-muted);">
${p.status === "ghost" ? "ghost mode" : p.location ? "sharing" : "no location"}
${p.status === "ghost" ? "ghost mode" : p.location ? (isStale ? ageLabel : "sharing") : "no location"}
${distLabel ? ` \u2022 ${distLabel}` : ""}
</div>
</div>
@ -1331,6 +1432,35 @@ class FolkMapViewer extends HTMLElement {
}
}
/** Periodically refresh staleness visuals on all participant markers */
private refreshStaleness() {
const state = this.sync?.getState();
if (!state) return;
for (const [id, p] of Object.entries(state.participants)) {
if (!p.location) continue;
const marker = this.participantMarkers.get(id);
if (!marker) continue;
const el = marker.getElement?.();
if (!el) continue;
const ageMs = Date.now() - new Date(p.location.timestamp).getTime();
const isStale = ageMs > STALE_THRESHOLD_MS;
el.style.opacity = isStale ? "0.5" : "1";
el.style.borderColor = isStale ? "#6b7280" : p.color;
const ageSec = Math.floor(ageMs / 1000);
const ageLabel = ageSec < 60 ? `${ageSec}s ago` : ageSec < 3600 ? `${Math.floor(ageSec / 60)}m ago` : "stale";
el.title = `${p.name} - ${p.status} (${ageLabel})`;
const arrow = el.querySelector(".heading-arrow") as HTMLElement | null;
if (arrow) arrow.style.borderBottomColor = isStale ? "#6b7280" : p.color;
}
}
private updateEmojiButton() {
const btn = this.shadow.getElementById("emoji-picker-btn");
if (btn) btn.textContent = this.userEmoji;
const picker = this.shadow.getElementById("emoji-picker-dropdown");
if (picker) picker.style.display = this.showEmojiPicker ? "flex" : "none";
}
private applyDarkFilter() {
const container = this.shadow.getElementById("map-container");
if (!container) return;
@ -2057,6 +2187,12 @@ class FolkMapViewer extends HTMLElement {
</div>
<div class="controls">
<div style="position:relative;">
<button class="ctrl-btn" id="emoji-picker-btn" title="Change your emoji avatar" style="font-size:18px;padding:4px 10px;">${this.userEmoji}</button>
<div id="emoji-picker-dropdown" style="display:none;position:absolute;bottom:100%;left:0;margin-bottom:6px;background:var(--rs-bg-surface);border:1px solid var(--rs-border-strong);border-radius:10px;padding:8px;gap:4px;flex-wrap:wrap;width:180px;box-shadow:0 4px 16px rgba(0,0,0,0.3);z-index:10;">
${EMOJIS.map(e => `<button class="emoji-opt" data-emoji-pick="${e}" style="width:32px;height:32px;border-radius:6px;border:1px solid ${e === this.userEmoji ? "#4f46e5" : "transparent"};background:${e === this.userEmoji ? "#4f46e520" : "none"};font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;">${e}</button>`).join("")}
</div>
</div>
<button class="ctrl-btn ${this.sharingLocation ? "sharing" : ""} ${this.privacySettings.ghostMode ? "ghost" : ""}" id="share-location">${this.privacySettings.ghostMode ? "\u{1F47B} Ghost Mode" : this.sharingLocation ? "\u{1F4CD} Stop Sharing" : "\u{1F4CD} Share Location"}</button>
<button class="ctrl-btn" id="privacy-toggle">\u{1F6E1} Privacy</button>
<button class="ctrl-btn" id="drop-waypoint">\u{1F4CC} Drop Pin</button>
@ -2108,6 +2244,17 @@ class FolkMapViewer extends HTMLElement {
}
});
// Emoji picker
this.shadow.getElementById("emoji-picker-btn")?.addEventListener("click", () => {
this.showEmojiPicker = !this.showEmojiPicker;
this.updateEmojiButton();
});
this.shadow.querySelectorAll("[data-emoji-pick]").forEach((btn) => {
btn.addEventListener("click", () => {
this.changeEmoji((btn as HTMLElement).dataset.emojiPick!);
});
});
this.shadow.getElementById("bell-toggle")?.addEventListener("click", () => {
this.pushManager?.toggle(this.room, this.participantId).then(subscribed => {
const bell = this.shadow.getElementById("bell-toggle");

View File

@ -4124,7 +4124,12 @@
document.getElementById("new-image").addEventListener("click", () => setPendingTool("folk-image"));
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-map").addEventListener("click", () => {
const roomSlug = prompt("Map room name (leave blank for solo map):");
if (roomSlug === null) return;
const slug = roomSlug.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-") || "";
setPendingTool("folk-map", slug ? { roomSlug: slug } : {});
});
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));