feat(mesh): show federated Archipelago nodes on the Mesh Map

Peers that opt in via a new "Share Location" toggle in Settings
(server.set-location RPC) get plotted on other trusted peers' Mesh Map
with a distinct Archy-logo marker, separate from raw LoRa radio peers.
Location is persisted locally, carried in NodeStateSnapshot, and
propagated through federation sync/delta like other node state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-01 12:04:31 -04:00
co-authored by Claude Sonnet 5
parent e3baaa5de3
commit 177b8a4338
15 changed files with 337 additions and 4 deletions
+2
View File
@@ -721,6 +721,8 @@ class RPCClient {
uptime_secs?: number
tor_active?: boolean
nostr_npub?: string
lat?: number | null
lon?: number | null
}
}>
}> {
+73 -3
View File
@@ -13,7 +13,7 @@ const markersLayer = ref<L.LayerGroup | null>(null)
const linesLayer = ref<L.LayerGroup | null>(null)
// Whether we have any position data to show
const hasPositions = computed(() => mesh.nodePositions.size > 0)
const hasPositions = computed(() => mesh.nodePositions.size > 0 || mesh.federatedPositions.size > 0)
// Location sharing state
const sharingLocation = ref(false)
@@ -108,6 +108,59 @@ function createMarkerIcon(type: 'self' | 'online' | 'offline'): L.DivIcon {
})
}
/// Marker for an Archipelago node (federation peer) — the little Archy logo
/// in a glowing badge, distinct from the plain colored dots used for raw
/// LoRa mesh-radio peers. Trusted nodes get the warm orange ring/glow the
/// rest of the map already uses for "this is us/ours"; Observer nodes get a
/// cooler blue so the trust boundary is visible at a glance.
function createFederatedMarkerIcon(trusted: boolean): L.DivIcon {
const ring = trusted ? '#fb923c' : '#38bdf8'
const glow = trusted ? 'rgba(251,146,60,0.55)' : 'rgba(56,189,248,0.5)'
const size = 30
return L.divIcon({
className: 'mesh-map-marker-wrapper',
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
popupAnchor: [0, -(size / 2 + 2)],
html: `
<div style="
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
width:${size + 14}px; height:${size + 14}px; border-radius:50%;
background:${glow}; animation:mesh-map-pulse 2.4s infinite;
"></div>
<div style="
width:${size}px; height:${size}px; border-radius:9px;
background:#0b0d14; border:2px solid ${ring};
box-shadow:0 0 10px ${glow}, 0 2px 6px rgba(0,0,0,0.5);
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
display:flex; align-items:center; justify-content:center;
z-index:3; overflow:hidden;
">
<img src="/assets/icon/apple-touch-icon-180x180-v2.png" width="${size - 6}" height="${size - 6}"
style="border-radius:6px;object-fit:cover;" alt="" />
</div>
`,
})
}
function buildFederatedPopupContent(name: string, onion: string, trusted: boolean): string {
const badge = trusted
? '<span style="display:inline-block;background:rgba(251,146,60,0.2);color:#fb923c;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">TRUSTED</span>'
: '<span style="display:inline-block;background:rgba(56,189,248,0.2);color:#38bdf8;font-size:0.65rem;padding:1px 6px;border-radius:4px;margin-left:6px;font-weight:600;">OBSERVER</span>'
const onionShort = onion.length > 20 ? `${onion.slice(0, 10)}...${onion.slice(-6)}` : onion
return `
<div style="font-family:'Avenir Next',sans-serif;min-width:170px;">
<div style="display:flex;align-items:center;gap:6px;font-weight:600;font-size:0.9rem;color:#fff;margin-bottom:4px;">
📡 ${name}${badge}
</div>
<div style="font-size:0.72rem;color:rgba(255,255,255,0.5);font-family:monospace;margin-bottom:6px;word-break:break-all;">
${onionShort}
</div>
<div style="font-size:0.78rem;color:rgba(255,255,255,0.6);">Archipelago node</div>
</div>
`
}
function getSignalBars(rssi: number | null): string {
if (rssi === null) return 'Unknown'
if (rssi >= -70) return 'Strong'
@@ -218,7 +271,8 @@ function updateMarkers() {
linesLayer.value.clearLayers()
const positions = mesh.nodePositions
if (positions.size === 0) return
const fedPositions = mesh.federatedPositions
if (positions.size === 0 && fedPositions.size === 0) return
const bounds: L.LatLngExpression[] = []
const selfPos = positions.get(-1)
@@ -267,6 +321,22 @@ function updateMarkers() {
}
})
// Archipelago nodes (federation peers who opted into location sharing) —
// own logo marker, no signal/hops (that's a radio-peer concept).
fedPositions.forEach((fed) => {
const marker = L.marker([fed.lat, fed.lng], {
icon: createFederatedMarkerIcon(fed.trusted),
zIndexOffset: 500,
})
marker.bindPopup(buildFederatedPopupContent(fed.name ?? 'Archipelago Node', fed.onion, fed.trusted), {
className: 'mesh-map-popup',
closeButton: true,
maxWidth: 250,
})
markersLayer.value!.addLayer(marker)
bounds.push([fed.lat, fed.lng])
})
// Fit map to show all markers
if (bounds.length > 1) {
map.fitBounds(L.latLngBounds(bounds), { padding: [40, 40], maxZoom: 14 })
@@ -283,7 +353,7 @@ function handleResize() {
// Watch for changes in node positions and peers
watch(
() => [mesh.nodePositions.size, mesh.peers.length],
() => [mesh.nodePositions.size, mesh.peers.length, mesh.federatedPositions.size],
() => {
updateMarkers()
},
+46
View File
@@ -179,6 +179,19 @@ export interface NodePosition {
timestamp: string
}
/// An Archipelago node (federation peer), not a raw LoRa radio peer — shown
/// on the Mesh Map with its own marker (the Archy logo) since it's a whole
/// server, not a mesh-radio contact. Only ones that opted into
/// server.set-location's `share` flag ever carry a lat/lon.
export interface FederatedNodePosition {
did: string
lat: number
lng: number
name: string | null
onion: string
trusted: boolean
}
export const useMeshStore = defineStore('mesh', () => {
const status = ref<MeshStatus | null>(null)
const peers = ref<MeshPeer[]>([])
@@ -192,6 +205,10 @@ export const useMeshStore = defineStore('mesh', () => {
// Node position tracking for map view (contact_id -> position)
const nodePositions = ref<Map<number, NodePosition>>(new Map())
// Federated Archipelago nodes with a shared location (DID -> position) —
// separate from nodePositions since these are whole servers, not radio
// peers, and render with a different marker on the Mesh Map.
const federatedPositions = ref<Map<string, FederatedNodePosition>>(new Map())
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
@@ -340,6 +357,33 @@ export const useMeshStore = defineStore('mesh', () => {
})
}
// Federation nodes that opted into sharing their location (server.set-location
// `share: true`) — fed by Mesh.vue's existing federation.list-nodes poll.
function updateFederatedPositions(nodes: Array<{
did: string
name?: string | null
onion: string
trust_level: string
last_state?: { lat?: number | null; lon?: number | null } | null
}>) {
const next = new Map<string, FederatedNodePosition>()
for (const n of nodes) {
const lat = n.last_state?.lat
const lon = n.last_state?.lon
if (typeof lat === 'number' && typeof lon === 'number') {
next.set(n.did, {
did: n.did,
lat,
lng: lon,
name: n.name ?? null,
onion: n.onion,
trusted: n.trust_level === 'trusted',
})
}
}
federatedPositions.value = next
}
function markChatRead(contactId: number) {
viewingChatId.value = contactId
delete unreadCounts.value[contactId]
@@ -799,6 +843,8 @@ export const useMeshStore = defineStore('mesh', () => {
unreadCounts,
totalUnread,
nodePositions,
federatedPositions,
updateFederatedPositions,
deadmanStatus,
blockHeaders,
latestBlockHeight,
+3
View File
@@ -30,6 +30,9 @@ export interface ServerInfo {
'wifi-ssids': string[]
'zram-enabled': boolean
'seed-backed': boolean
lat?: number | null
lon?: number | null
'share-location'?: boolean
}
export interface StatusInfo {
+1
View File
@@ -130,6 +130,7 @@ async function refreshFederationNodes() {
})
}
fedNodesByDid.value = next
mesh.updateFederatedPositions(res.nodes)
} catch { /* non-fatal */ }
}
async function refreshSelfOnion() {
@@ -68,6 +68,60 @@ const copiedOnion = ref(false)
const copiedDid = ref(false)
let copiedTimer: ReturnType<typeof setTimeout> | null = null
// Location sharing opt-in only, off by default. Lets this node's own
// position appear on OTHER trusted federation peers' Mesh Map (with the
// Archy logo marker), the same way a mesh radio peer's position shows up.
// Backed by the store's serverInfo (already synced live over the WS), not
// a separate fetch, so it stays in sync with any other tab/session too.
const shareLocation = computed(() => !!store.serverInfo?.['share-location'])
const savedLat = computed(() => store.serverInfo?.lat ?? null)
const savedLon = computed(() => store.serverInfo?.lon ?? null)
const locationSaving = ref(false)
const locationError = ref('')
async function useCurrentLocation() {
locationError.value = ''
if (!navigator.geolocation) {
locationError.value = 'Geolocation not supported by this browser'
return
}
locationSaving.value = true
navigator.geolocation.getCurrentPosition(
async (pos) => {
await saveLocation(pos.coords.latitude, pos.coords.longitude, shareLocation.value)
locationSaving.value = false
},
(err) => {
locationError.value = err.code === 1 ? 'Location permission denied' : err.message
locationSaving.value = false
},
{ enableHighAccuracy: true, timeout: 15000 },
)
}
async function toggleShareLocation() {
const next = !shareLocation.value
await saveLocation(savedLat.value, savedLon.value, next)
}
async function saveLocation(lat: number | null, lon: number | null, share: boolean) {
try {
locationSaving.value = true
await rpcClient.call({ method: 'server.set-location', params: { lat, lon, share } })
// Optimistic update the next WS state push confirms it, but no need
// to wait for that round-trip to reflect the change in the toggle/coords.
if (store.serverInfo) {
store.serverInfo.lat = lat
store.serverInfo.lon = lon
store.serverInfo['share-location'] = share
}
} catch (e) {
locationError.value = e instanceof Error ? e.message : 'Failed to save location'
} finally {
locationSaving.value = false
}
}
// mDNS hostname HTTPS (even self-signed) is required for mic/camera access
// (getUserMedia refuses plain HTTP outside localhost); surface both so users
// know where to go for features that need it, without forcing HTTPS on anyone.
@@ -260,6 +314,41 @@ init()
</div>
</div>
<!-- Location Sharing Card opt-in, off by default. Puts this node on
OTHER trusted peers' Mesh Map with the Archy logo marker. -->
<div class="bg-black/20 rounded-xl px-5 py-4 border border-white/10">
<div class="flex items-center gap-3 mb-2">
<svg class="w-5 h-5 text-white/70" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<p class="text-xs font-semibold text-white/60 uppercase tracking-wide">Share Location</p>
</div>
<div class="flex items-center justify-between gap-3 mb-1">
<p class="text-sm text-white/70">Show this node on trusted peers' Mesh Map</p>
<button
class="glass-toggle"
:class="{ active: shareLocation }"
role="switch"
:aria-checked="shareLocation"
:disabled="locationSaving"
@click="toggleShareLocation"
>
<span class="glass-toggle-knob" />
</button>
</div>
<div class="flex items-center gap-2 text-xs text-white/50 mt-2">
<span v-if="savedLat !== null && savedLon !== null">{{ savedLat.toFixed(3) }}, {{ savedLon.toFixed(3) }}</span>
<span v-else class="text-white/30">No location set</span>
<button
class="ml-auto glass-button px-3 py-1 text-xs"
:disabled="locationSaving"
@click="useCurrentLocation"
>{{ locationSaving ? 'Locating…' : 'Use current location' }}</button>
</div>
<p v-if="locationError" class="mt-2 text-xs text-yellow-300">{{ locationError }}</p>
</div>
<!-- Release Notes Modal -->
<Teleport to="body">
<Transition name="modal">