From 45cca12d6ea66e250aab267f76e165701f20e2a5 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 15:09:13 +0100 Subject: [PATCH 1/6] refactor(map): extract buildSegments from TracerouteMap Share path segment builder for traceroute and message heard maps. --- src/components/traceroutes/TracerouteMap.tsx | 56 +------------------ src/lib/map-path-segments.test.ts | 36 ++++++++++++ src/lib/map-path-segments.ts | 58 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 54 deletions(-) create mode 100644 src/lib/map-path-segments.test.ts create mode 100644 src/lib/map-path-segments.ts diff --git a/src/components/traceroutes/TracerouteMap.tsx b/src/components/traceroutes/TracerouteMap.tsx index 0c0f513..72d39dd 100644 --- a/src/components/traceroutes/TracerouteMap.tsx +++ b/src/components/traceroutes/TracerouteMap.tsx @@ -1,4 +1,5 @@ -import { AutoTraceRoute, TracerouteRouteNode } from '@/lib/models'; +import { AutoTraceRoute } from '@/lib/models'; +import { buildSegments, midpoint, type LatLng } from '@/lib/map-path-segments'; import { useMapTileUrl } from '@/hooks/useMapTileUrl'; import { createNodeIcon } from '@/components/nodes/map-utils'; import L from 'leaflet'; @@ -12,8 +13,6 @@ const INTERMEDIATE_COLOR = '#64748b'; const FAILED_LINE_COLOR = '#dc2626'; const UNKNOWN_NODE_ID = 0xffffffff; -type LatLng = [number, number]; - function getSourcePos(tr: AutoTraceRoute): LatLng | null { const pos = tr.source_node?.position; if (pos?.latitude != null && pos?.longitude != null) { @@ -30,57 +29,6 @@ function getTargetPos(tr: AutoTraceRoute): LatLng | null { return null; } -interface Segment { - latlngs: LatLng[]; - dashed: boolean; - unknownLabels: { node_id_str: string }[]; -} - -function buildSegments(startPos: LatLng | null, nodes: TracerouteRouteNode[], endPos: LatLng | null): Segment[] { - if (!startPos || !endPos) return []; - const segments: Segment[] = []; - let solidRun: LatLng[] = [startPos]; - let pendingUnknowns: { node_id_str: string }[] = []; - - for (const node of nodes) { - if (node.position) { - const pos: LatLng = [node.position.latitude, node.position.longitude]; - if (pendingUnknowns.length > 0) { - segments.push({ - latlngs: [solidRun[solidRun.length - 1], pos], - dashed: true, - unknownLabels: pendingUnknowns, - }); - pendingUnknowns = []; - } - solidRun.push(pos); - } else { - if (solidRun.length >= 2) { - segments.push({ latlngs: [...solidRun], dashed: false, unknownLabels: [] }); - } - solidRun = [solidRun[solidRun.length - 1]]; - pendingUnknowns.push({ node_id_str: node.node_id_str }); - } - } - - if (pendingUnknowns.length > 0) { - segments.push({ - latlngs: [solidRun[solidRun.length - 1], endPos], - dashed: true, - unknownLabels: pendingUnknowns, - }); - } else { - solidRun.push(endPos); - segments.push({ latlngs: solidRun, dashed: false, unknownLabels: [] }); - } - - return segments; -} - -function midpoint(a: LatLng, b: LatLng): LatLng { - return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; -} - export function TracerouteMap({ traceroute }: { traceroute: AutoTraceRoute }) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); diff --git a/src/lib/map-path-segments.test.ts b/src/lib/map-path-segments.test.ts new file mode 100644 index 0000000..77f8f59 --- /dev/null +++ b/src/lib/map-path-segments.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { buildSegments } from './map-path-segments'; +import type { TracerouteRouteNode } from '@/lib/models'; + +const start: [number, number] = [55.0, -4.0]; +const end: [number, number] = [55.1, -4.1]; + +describe('buildSegments', () => { + it('returns dashed segment with unknown labels when hops lack position', () => { + const nodes: TracerouteRouteNode[] = [ + { + meshtastic_node_id: 0xffffffff, + node_id_str: 'ab', + short_name: 'ab', + position: null, + }, + ]; + const segments = buildSegments(start, nodes, end); + expect(segments).toHaveLength(1); + expect(segments[0].dashed).toBe(true); + expect(segments[0].unknownLabels).toEqual([{ node_id_str: 'ab' }]); + }); + + it('returns solid run when all hops have positions', () => { + const nodes: TracerouteRouteNode[] = [ + { + meshtastic_node_id: 3, + node_id_str: '!00000003', + short_name: 'H1', + position: { latitude: 55.05, longitude: -4.05 }, + }, + ]; + const segments = buildSegments(start, nodes, end); + expect(segments.some((s) => !s.dashed)).toBe(true); + }); +}); diff --git a/src/lib/map-path-segments.ts b/src/lib/map-path-segments.ts new file mode 100644 index 0000000..d2382d5 --- /dev/null +++ b/src/lib/map-path-segments.ts @@ -0,0 +1,58 @@ +import type { TracerouteRouteNode } from '@/lib/models'; + +export type LatLng = [number, number]; + +export interface PathSegment { + latlngs: LatLng[]; + dashed: boolean; + unknownLabels: { node_id_str: string }[]; +} + +export function buildSegments( + startPos: LatLng | null, + nodes: TracerouteRouteNode[], + endPos: LatLng | null +): PathSegment[] { + if (!startPos || !endPos) return []; + const segments: PathSegment[] = []; + let solidRun: LatLng[] = [startPos]; + let pendingUnknowns: { node_id_str: string }[] = []; + + for (const node of nodes) { + if (node.position) { + const pos: LatLng = [node.position.latitude, node.position.longitude]; + if (pendingUnknowns.length > 0) { + segments.push({ + latlngs: [solidRun[solidRun.length - 1], pos], + dashed: true, + unknownLabels: pendingUnknowns, + }); + pendingUnknowns = []; + } + solidRun.push(pos); + } else { + if (solidRun.length >= 2) { + segments.push({ latlngs: [...solidRun], dashed: false, unknownLabels: [] }); + } + solidRun = [solidRun[solidRun.length - 1]]; + pendingUnknowns.push({ node_id_str: node.node_id_str }); + } + } + + if (pendingUnknowns.length > 0) { + segments.push({ + latlngs: [solidRun[solidRun.length - 1], endPos], + dashed: true, + unknownLabels: pendingUnknowns, + }); + } else { + solidRun.push(endPos); + segments.push({ latlngs: solidRun, dashed: false, unknownLabels: [] }); + } + + return segments; +} + +export function midpoint(a: LatLng, b: LatLng): LatLng { + return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; +} From 41b4f6b4760d1f62760214f7df044f3ee722742b Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 15:10:26 +0100 Subject: [PATCH 2/6] feat(messages): HeardPathMap for message heard dialog Protocol-agnostic map with dashed paths when hop positions unknown. --- src/components/messages/HeardPathMap.tsx | 180 ++++++++++++++++++ .../messages/heard-path-map-adapters.test.ts | 89 +++++++++ .../messages/heard-path-map-adapters.ts | 92 +++++++++ src/lib/models.ts | 32 +++- 4 files changed, 391 insertions(+), 2 deletions(-) create mode 100644 src/components/messages/HeardPathMap.tsx create mode 100644 src/components/messages/heard-path-map-adapters.test.ts create mode 100644 src/components/messages/heard-path-map-adapters.ts diff --git a/src/components/messages/HeardPathMap.tsx b/src/components/messages/HeardPathMap.tsx new file mode 100644 index 0000000..cb5a963 --- /dev/null +++ b/src/components/messages/HeardPathMap.tsx @@ -0,0 +1,180 @@ +import { buildSegments, midpoint, type LatLng } from '@/lib/map-path-segments'; +import type { TracerouteRouteNode } from '@/lib/models'; +import { useMapTileUrl } from '@/hooks/useMapTileUrl'; +import { createNodeIcon } from '@/components/nodes/map-utils'; +import L from 'leaflet'; +import { useEffect, useRef } from 'react'; +import 'leaflet/dist/leaflet.css'; + +const DEFAULT_CENTER: LatLng = [55.8642, -4.2518]; +const SENDER_COLOR = '#16a34a'; +const LEG_COLORS = ['#2563eb', '#0891b2', '#7c3aed', '#db2777', '#ea580c']; + +export type MapPosition = { latitude: number; longitude: number }; + +export type HeardPathLeg = { + receiver: { label: string; position: MapPosition }; + waypoints: TracerouteRouteNode[]; + pathKnown: boolean; + lineColor?: string; +}; + +export type HeardPathMapProps = { + sender: { label: string; position: MapPosition } | null; + legs: HeardPathLeg[]; +}; + +function toLatLng(pos: MapPosition): LatLng { + return [pos.latitude, pos.longitude]; +} + +function hasPositionedWaypoints(waypoints: TracerouteRouteNode[]): boolean { + return waypoints.some((node) => node.position != null); +} + +export function HeardPathMap({ sender, legs }: HeardPathMapProps) { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const tileLayerRef = useRef(null); + const layersRef = useRef([]); + const { url: tileUrl, attribution } = useMapTileUrl(); + + const senderPos = sender ? toLatLng(sender.position) : null; + + useEffect(() => { + if (mapRef.current && !mapInstanceRef.current) { + const map = L.map(mapRef.current).setView(DEFAULT_CENTER, 13); + const tileLayer = L.tileLayer(tileUrl, { attribution }).addTo(map); + tileLayerRef.current = tileLayer; + mapInstanceRef.current = map; + + const style = document.createElement('style'); + style.id = 'heard-path-map-styles'; + style.textContent = ` + .map-container .leaflet-tile-pane { z-index: 1; } + .map-container .leaflet-overlay-pane { z-index: 400; } + .map-container .leaflet-marker-pane { z-index: 600; } + .map-container .leaflet-tooltip-pane { z-index: 650; } + .traceroute-unknown-label { font-size: 11px; font-family: monospace; background: rgba(255,255,255,0.9); border: 1px dashed #999; padding: 2px 6px; } + `; + document.head.appendChild(style); + + return () => { + map.remove(); + mapInstanceRef.current = null; + tileLayerRef.current = null; + style.remove(); + }; + } + }, []); + + useEffect(() => { + const map = mapInstanceRef.current; + const oldLayer = tileLayerRef.current; + if (map && oldLayer) { + map.removeLayer(oldLayer); + const newLayer = L.tileLayer(tileUrl, { attribution }).addTo(map); + tileLayerRef.current = newLayer; + } + }, [tileUrl, attribution]); + + useEffect(() => { + const map = mapInstanceRef.current; + if (!map || !senderPos) return; + + layersRef.current.forEach((layer) => layer.remove()); + layersRef.current = []; + + const bounds = L.latLngBounds([]); + const senderMarker = L.marker(senderPos, { + icon: createNodeIcon(sender?.label ?? 'S', SENDER_COLOR, false), + }).addTo(map); + layersRef.current.push(senderMarker); + bounds.extend(senderPos); + + legs.forEach((leg, index) => { + const receiverPos = toLatLng(leg.receiver.position); + const color = leg.lineColor ?? LEG_COLORS[index % LEG_COLORS.length]; + const receiverMarker = L.marker(receiverPos, { + icon: createNodeIcon(leg.receiver.label, color, false), + }).addTo(map); + layersRef.current.push(receiverMarker); + bounds.extend(receiverPos); + + const segments = + leg.pathKnown && hasPositionedWaypoints(leg.waypoints) + ? buildSegments(senderPos, leg.waypoints, receiverPos) + : [ + { + latlngs: [senderPos, receiverPos] as LatLng[], + dashed: true, + unknownLabels: leg.waypoints.map((node) => ({ node_id_str: node.node_id_str })), + }, + ]; + + segments.forEach((seg) => { + const poly = L.polyline(seg.latlngs, { + color, + weight: 4, + dashArray: seg.dashed ? '10, 10' : undefined, + }).addTo(map); + layersRef.current.push(poly); + seg.latlngs.forEach((p) => bounds.extend(p)); + + if (seg.dashed && seg.unknownLabels.length > 0 && seg.latlngs.length >= 2) { + const mid = midpoint(seg.latlngs[0], seg.latlngs[seg.latlngs.length - 1]); + seg.unknownLabels.forEach((label) => { + const tooltip = L.tooltip({ + permanent: true, + direction: 'center', + className: 'traceroute-unknown-label', + }) + .setContent(label.node_id_str) + .setLatLng(mid); + tooltip.addTo(map); + layersRef.current.push(tooltip); + }); + } + }); + }); + + if (bounds.isValid()) { + map.invalidateSize(); + const t = setTimeout(() => { + if (mapInstanceRef.current !== map) return; + map.invalidateSize(); + const singlePoint = bounds.getNorthEast().equals(bounds.getSouthWest()); + if (singlePoint) { + map.setView(bounds.getCenter(), 13); + } else { + map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 }); + } + }, 50); + return () => clearTimeout(t); + } + }, [sender, senderPos, legs]); + + if (!senderPos) { + return ( +
+ No map — sender position unknown +
+ ); + } + + if (legs.length === 0) { + return ( +
+ No feeder positions available for map +
+ ); + } + + return ( +
+ ); +} diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts new file mode 100644 index 0000000..bd67c61 --- /dev/null +++ b/src/components/messages/heard-path-map-adapters.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { meshtasticHeardToLegs, meshCoreHeardToLegs } from './heard-path-map-adapters'; +import type { TextMessage } from '@/lib/models'; + +describe('heard path adapters', () => { + it('meshtasticHeardToLegs uses dashed path when path_known is false', () => { + const message: TextMessage = { + id: '1', + packet_id: 1, + protocol: 'meshtastic', + sender: { node_id_str: '!1', long_name: 'S', short_name: 'S' }, + sender_position: { latitude: 55.0, longitude: -4.0 }, + recipient_meshtastic_node_id: 0xffffffff, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'hi', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + meshtastic_node_id: 2, + node_id_str: '!2', + long_name: 'F', + short_name: 'F', + }, + observer_position: { latitude: 55.1, longitude: -4.1 }, + rx_time: new Date().toISOString(), + rx_rssi: -80, + rx_snr: 5, + direct_from_sender: true, + hop_count: 0, + path_known: false, + }, + ], + }; + const { sender, legs } = meshtasticHeardToLegs(message); + expect(sender?.position.latitude).toBe(55.0); + expect(legs).toHaveLength(1); + expect(legs[0].pathKnown).toBe(false); + expect(legs[0].waypoints).toHaveLength(0); + }); + + it('meshCoreHeardToLegs maps resolved_path to waypoints without position', () => { + const message: TextMessage = { + id: '2', + packet_id: 2, + protocol: 'meshcore', + sender: { node_id_str: 'mc:abc', long_name: 'X', short_name: 'X' }, + sender_position: { latitude: 55.0, longitude: -4.0 }, + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'mc', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + node_id_str: 'mc:feed', + internal_id: null, + long_name: 'Feeder', + short_name: 'F', + position: { latitude: 55.2, longitude: -4.2 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa', 'bb'], + resolved_path: [ + { + hash: 'aa', + status: 'unknown', + node_id_str: null, + internal_id: null, + long_name: null, + ambiguous: false, + }, + ], + path_known: false, + }, + ], + }; + const { legs } = meshCoreHeardToLegs(message); + expect(legs[0].waypoints[0].node_id_str).toBe('aa'); + expect(legs[0].waypoints[0].position).toBeNull(); + expect(legs[0].pathKnown).toBe(false); + }); +}); diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts new file mode 100644 index 0000000..c187bbd --- /dev/null +++ b/src/components/messages/heard-path-map-adapters.ts @@ -0,0 +1,92 @@ +import type { MapPosition, MeshCoreHeardObservation, PacketObservation, ResolvedHop, TextMessage } from '@/lib/models'; +import type { TracerouteRouteNode } from '@/lib/models'; +import type { HeardPathLeg } from './HeardPathMap'; + +const UNKNOWN_NODE_ID = 0xffffffff; + +export function isMeshCoreHeardObservation( + obs: PacketObservation | MeshCoreHeardObservation +): obs is MeshCoreHeardObservation { + return 'path_hashes' in obs; +} + +function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode { + return { + meshtastic_node_id: UNKNOWN_NODE_ID, + node_id_str: hop.hash, + short_name: hop.hash, + position: null, + }; +} + +export function meshtasticHeardToLegs(message: TextMessage): { + sender: { label: string; position: MapPosition } | null; + legs: HeardPathLeg[]; +} { + const senderPos = message.sender_position; + const sender = + message.sender && senderPos + ? { + label: message.sender.short_name || message.sender.node_id_str, + position: senderPos, + } + : null; + + const legs: HeardPathLeg[] = []; + for (const obs of message.heard) { + if (isMeshCoreHeardObservation(obs)) continue; + const position = obs.observer_position; + if (!position) continue; + legs.push({ + receiver: { + label: obs.observer.short_name || obs.observer.node_id_str, + position, + }, + waypoints: [], + pathKnown: obs.path_known ?? false, + }); + } + return { sender, legs }; +} + +export function meshCoreHeardToLegs(message: TextMessage): { + sender: { label: string; position: MapPosition } | null; + legs: HeardPathLeg[]; +} { + const senderPos = message.sender_position; + const sender = + message.sender && senderPos + ? { + label: message.sender.short_name || message.sender.node_id_str, + position: senderPos, + } + : null; + + const legs: HeardPathLeg[] = []; + for (const obs of message.heard) { + if (!isMeshCoreHeardObservation(obs)) continue; + const position = obs.observer.position; + if (!position) continue; + const waypoints = (obs.resolved_path ?? []).map(hopToWaypoint); + legs.push({ + receiver: { + label: obs.observer.short_name || obs.observer.node_id_str, + position, + }, + waypoints, + pathKnown: obs.path_known ?? false, + }); + } + return { sender, legs }; +} + +export function messageToHeardPathLegs(message: TextMessage): { + sender: { label: string; position: MapPosition } | null; + legs: HeardPathLeg[]; +} { + const proto = message.protocol?.toString().toLowerCase(); + if (proto === 'meshcore' || proto === '2') { + return meshCoreHeardToLegs(message); + } + return meshtasticHeardToLegs(message); +} diff --git a/src/lib/models.ts b/src/lib/models.ts index 954f15c..230f669 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -329,6 +329,11 @@ export interface TextMessageSender { short_name: string | null; } +export interface MapPosition { + latitude: number; + longitude: number; +} + export interface PacketObservationObserver { meshtastic_node_id: number; node_id_str: string; @@ -338,19 +343,41 @@ export interface PacketObservationObserver { export interface PacketObservation { observer: PacketObservationObserver; + observer_position?: MapPosition | null; rx_time: string; // ISO date string rx_rssi: number | null; rx_snr: number | null; direct_from_sender: boolean; hop_count: number | null; + path_known?: boolean; +} + +export interface ResolvedHop { + hash: string; + status: 'unknown' | 'ambiguous' | 'resolved'; + node_id_str: string | null; + internal_id: string | null; + long_name: string | null; + ambiguous: boolean; +} + +export interface HeardObserver { + node_id_str: string; + internal_id: string | null; + long_name: string | null; + short_name: string | null; + position: MapPosition | null; } -/** MeshCore heard row shape from text message API (observer is node_id_str string). */ +/** MeshCore heard row from text message API. */ export interface MeshCoreHeardObservation { - observer: string; + observer: HeardObserver; rx_time: string; rx_rssi: number | null; rx_snr: number | null; + path_hashes?: string[] | null; + resolved_path?: ResolvedHop[]; + path_known: boolean; } export interface TextMessage { @@ -358,6 +385,7 @@ export interface TextMessage { packet_id: number | string; protocol?: MeshProtocol | 'meshtastic' | 'meshcore' | string; sender: TextMessageSender | null; + sender_position?: MapPosition | null; recipient_meshtastic_node_id: number | null; channel: number; sent_at: string; // ISO date string From 6098719989a3f4e1ee47a46e54d55d25d572071d Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 15:11:01 +0100 Subject: [PATCH 3/6] feat(messages): integrate map into HeardDialog for MT and MC Widen heard dialog; show sender, feeders, path hash badges for MeshCore. --- src/components/messages/MessageItem.tsx | 62 ++++++++++++++++++------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index 671b235..ddae46f 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -1,5 +1,7 @@ import { TextMessage, type PacketObservation, type MeshCoreHeardObservation } from '@/lib/models'; import { messageProtocol } from '@/lib/message-protocol'; +import { HeardPathMap } from '@/components/messages/HeardPathMap'; +import { isMeshCoreHeardObservation, messageToHeardPathLegs } from '@/components/messages/heard-path-map-adapters'; import { Avatar } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -11,22 +13,20 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { ExternalLink } from 'lucide-react'; import { nodeDetailPath } from '@/lib/node-detail-routes'; -function isMeshCoreHeard(obs: PacketObservation | MeshCoreHeardObservation): obs is MeshCoreHeardObservation { - return typeof (obs as MeshCoreHeardObservation).observer === 'string'; -} - function HeardDialog({ - observations, - protocol, + message, size = 'sm', className, }: { - observations: TextMessage['heard'] | undefined; - protocol: ReturnType; + message: TextMessage; size?: 'sm' | 'xs'; className?: string; }) { + const observations = message.heard; const count = observations?.length || 0; + const { sender, legs } = useMemo(() => messageToHeardPathLegs(message), [message]); + const protocol = messageProtocol(message); + return ( @@ -43,22 +43,52 @@ function HeardDialog({ {count} heard - + Message Heard By +
{observations?.length ? ( observations.map((observation, index) => { - if (protocol === 'meshcore' || isMeshCoreHeard(observation)) { + if (protocol === 'meshcore' || isMeshCoreHeardObservation(observation)) { const mc = observation as MeshCoreHeardObservation; + const observerLabel = mc.observer.short_name || mc.observer.node_id_str; + const observerLink = nodeDetailPath({ + internal_id: mc.observer.internal_id ?? undefined, + node_id_str: mc.observer.node_id_str, + protocol: 2, + }); return ( -
+
-
{mc.observer}
+
+ {observerLink ? ( + + {observerLabel} + + ) : ( + observerLabel + )} +
+ {mc.observer.long_name && ( +
{mc.observer.long_name}
+ )}
{format(new Date(mc.rx_time), 'MMM d, yyyy h:mm a')}
+ {mc.resolved_path && mc.resolved_path.length > 0 && ( +
+ {mc.resolved_path.map((hop) => ( + + {hop.hash} + + ))} +
+ )}
{mc.rx_rssi != null &&
RSSI: {mc.rx_rssi.toFixed(1)}
} @@ -184,7 +214,7 @@ export const MessageItem = memo(function MessageItem({ title={fullTime} /> ) : null} - +

{message.message_text}

@@ -210,7 +240,7 @@ export const MessageItem = memo(function MessageItem({ {reply.sent_at ? format(new Date(reply.sent_at), 'MMM d, h:mm a') : ''} - +
))}
@@ -241,7 +271,7 @@ export const MessageItem = memo(function MessageItem({ {contMsg.is_emoji && (emoji)}
{contTime} - +
{contReplies.length > 0 && (
@@ -254,7 +284,7 @@ export const MessageItem = memo(function MessageItem({ {reply.sent_at ? format(new Date(reply.sent_at), 'MMM d, h:mm a') : ''} - +
))}
From 04a930046e35688cbba7212544799a4765c53ddd Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 16:18:53 +0100 Subject: [PATCH 4/6] fix(messages): heard map markers and MC inferred sender position Shared Leaflet marker CSS; mc_sender_candidates for map when unambiguous. --- src/components/messages/HeardPathMap.tsx | 12 +-- src/components/messages/MessageItem.tsx | 5 ++ .../messages/heard-path-map-adapters.test.ts | 82 +++++++++++++++++++ .../messages/heard-path-map-adapters.ts | 59 +++++++++---- src/lib/map-marker-styles.ts | 24 ++++++ src/lib/models.ts | 11 +++ 6 files changed, 168 insertions(+), 25 deletions(-) create mode 100644 src/lib/map-marker-styles.ts diff --git a/src/components/messages/HeardPathMap.tsx b/src/components/messages/HeardPathMap.tsx index cb5a963..ad92c87 100644 --- a/src/components/messages/HeardPathMap.tsx +++ b/src/components/messages/HeardPathMap.tsx @@ -1,4 +1,5 @@ import { buildSegments, midpoint, type LatLng } from '@/lib/map-path-segments'; +import { MAP_NODE_MARKER_CSS } from '@/lib/map-marker-styles'; import type { TracerouteRouteNode } from '@/lib/models'; import { useMapTileUrl } from '@/hooks/useMapTileUrl'; import { createNodeIcon } from '@/components/nodes/map-utils'; @@ -50,13 +51,7 @@ export function HeardPathMap({ sender, legs }: HeardPathMapProps) { const style = document.createElement('style'); style.id = 'heard-path-map-styles'; - style.textContent = ` - .map-container .leaflet-tile-pane { z-index: 1; } - .map-container .leaflet-overlay-pane { z-index: 400; } - .map-container .leaflet-marker-pane { z-index: 600; } - .map-container .leaflet-tooltip-pane { z-index: 650; } - .traceroute-unknown-label { font-size: 11px; font-family: monospace; background: rgba(255,255,255,0.9); border: 1px dashed #999; padding: 2px 6px; } - `; + style.textContent = MAP_NODE_MARKER_CSS; document.head.appendChild(style); return () => { @@ -149,7 +144,8 @@ export function HeardPathMap({ sender, legs }: HeardPathMapProps) { } else { map.fitBounds(bounds, { padding: [40, 40], maxZoom: 15 }); } - }, 50); + map.invalidateSize(); + }, 150); return () => clearTimeout(t); } }, [sender, senderPos, legs]); diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index ddae46f..ef69755 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -48,6 +48,11 @@ function HeardDialog({ Message Heard By + {message.mc_sender_candidates && message.mc_sender_candidates.length > 1 && ( +

+ Multiple nodes match sender "{message.mc_sender_label}" — map uses feeder positions only. +

+ )}
{observations?.length ? ( observations.map((observation, index) => { diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts index bd67c61..398ff80 100644 --- a/src/components/messages/heard-path-map-adapters.test.ts +++ b/src/components/messages/heard-path-map-adapters.test.ts @@ -41,6 +41,88 @@ describe('heard path adapters', () => { expect(legs[0].waypoints).toHaveLength(0); }); + it('meshCoreHeardToLegs uses single mc_sender_candidate with position as sender', () => { + const message: TextMessage = { + id: '3', + packet_id: 3, + protocol: 'meshcore', + sender: null, + sender_position: { latitude: 55.0, longitude: -4.0 }, + mc_sender_label: 'WMF', + mc_sender_candidates: [ + { + internal_id: '00000000-0000-4000-8000-000000000099', + node_id_str: 'mc:wmf', + long_name: 'WMF', + short_name: 'WMF', + position: { latitude: 55.0, longitude: -4.0 }, + }, + ], + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'WMF: hi', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [], + }; + const { sender } = meshCoreHeardToLegs(message); + expect(sender?.label).toBe('WMF'); + expect(sender?.position.latitude).toBe(55.0); + }); + + it('meshCoreHeardToLegs omits sender when multiple positioned candidates', () => { + const pos = { latitude: 55.0, longitude: -4.0 }; + const message: TextMessage = { + id: '4', + packet_id: 4, + protocol: 'meshcore', + sender: null, + mc_sender_label: 'WMF', + mc_sender_candidates: [ + { + internal_id: '1', + node_id_str: 'mc:a', + long_name: 'WMF', + short_name: 'A', + position: pos, + }, + { + internal_id: '2', + node_id_str: 'mc:b', + long_name: 'WMF', + short_name: 'B', + position: pos, + }, + ], + recipient_meshtastic_node_id: null, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'WMF: hi', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [ + { + observer: { + node_id_str: 'mc:feed', + internal_id: null, + long_name: 'Feeder', + short_name: 'F', + position: { latitude: 55.2, longitude: -4.2 }, + }, + rx_time: new Date().toISOString(), + rx_rssi: -90, + rx_snr: 2, + path_hashes: ['aa'], + path_known: false, + }, + ], + }; + const { sender, legs } = meshCoreHeardToLegs(message); + expect(sender).toBeNull(); + expect(legs).toHaveLength(1); + }); + it('meshCoreHeardToLegs maps resolved_path to waypoints without position', () => { const message: TextMessage = { id: '2', diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts index c187bbd..336ce76 100644 --- a/src/components/messages/heard-path-map-adapters.ts +++ b/src/components/messages/heard-path-map-adapters.ts @@ -1,4 +1,11 @@ -import type { MapPosition, MeshCoreHeardObservation, PacketObservation, ResolvedHop, TextMessage } from '@/lib/models'; +import type { + MapPosition, + McSenderCandidate, + MeshCoreHeardObservation, + PacketObservation, + ResolvedHop, + TextMessage, +} from '@/lib/models'; import type { TracerouteRouteNode } from '@/lib/models'; import type { HeardPathLeg } from './HeardPathMap'; @@ -10,6 +17,38 @@ export function isMeshCoreHeardObservation( return 'path_hashes' in obs; } +function senderFromMcCandidates(candidates: McSenderCandidate[] | undefined): { + label: string; + position: MapPosition; +} | null { + if (!candidates?.length) return null; + const positioned = candidates.filter((c) => c.position != null) as Array< + McSenderCandidate & { position: MapPosition } + >; + if (positioned.length !== 1) return null; + const node = positioned[0]; + return { + label: node.short_name || node.long_name || node.node_id_str, + position: node.position, + }; +} + +function mapSender(message: TextMessage): { label: string; position: MapPosition } | null { + if (message.sender && message.sender_position) { + return { + label: message.sender.short_name || message.sender.node_id_str, + position: message.sender_position, + }; + } + if (message.sender_position) { + return { + label: message.sender?.short_name || message.mc_sender_label || message.sender?.node_id_str || 'Sender', + position: message.sender_position, + }; + } + return senderFromMcCandidates(message.mc_sender_candidates); +} + function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode { return { meshtastic_node_id: UNKNOWN_NODE_ID, @@ -23,14 +62,7 @@ export function meshtasticHeardToLegs(message: TextMessage): { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; } { - const senderPos = message.sender_position; - const sender = - message.sender && senderPos - ? { - label: message.sender.short_name || message.sender.node_id_str, - position: senderPos, - } - : null; + const sender = mapSender(message); const legs: HeardPathLeg[] = []; for (const obs of message.heard) { @@ -53,14 +85,7 @@ export function meshCoreHeardToLegs(message: TextMessage): { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; } { - const senderPos = message.sender_position; - const sender = - message.sender && senderPos - ? { - label: message.sender.short_name || message.sender.node_id_str, - position: senderPos, - } - : null; + const sender = mapSender(message); const legs: HeardPathLeg[] = []; for (const obs of message.heard) { diff --git a/src/lib/map-marker-styles.ts b/src/lib/map-marker-styles.ts new file mode 100644 index 0000000..84b0e27 --- /dev/null +++ b/src/lib/map-marker-styles.ts @@ -0,0 +1,24 @@ +/** Shared Leaflet divIcon marker CSS for map-container maps. */ +export const MAP_NODE_MARKER_CSS = ` + .map-container .leaflet-tile-pane { z-index: 1; } + .map-container .leaflet-overlay-pane { z-index: 400; } + .map-container .leaflet-marker-pane { z-index: 600; } + .map-container .leaflet-tooltip-pane { z-index: 650; } + .custom-node-marker { background: transparent; border: none; } + .marker-container { position: relative; text-align: center; } + .marker-pin { + width: 35px; height: 35px; border-radius: 50% 50% 50% 0; + position: absolute; transform: rotate(-45deg); + left: 50%; top: 50%; margin: -2.5px 0 0 -17.5px; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + } + .marker-text { + position: absolute; width: 40px; left: 60%; transform: translateX(-50%); + top: 5px; text-align: center; color: white; font-weight: bold; font-size: 12px; + text-shadow: 1px 1px 2px rgba(0,0,0,0.5); + } + .traceroute-unknown-label { + font-size: 11px; font-family: monospace; + background: rgba(255,255,255,0.9); border: 1px dashed #999; padding: 2px 6px; + } +`; diff --git a/src/lib/models.ts b/src/lib/models.ts index 230f669..8ca12e3 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -329,6 +329,15 @@ export interface TextMessageSender { short_name: string | null; } +/** MC channel sender inferred from ``{name}: {body}`` prefix (see API docs). */ +export interface McSenderCandidate { + internal_id: string; + node_id_str: string; + long_name: string | null; + short_name: string | null; + position: MapPosition | null; +} + export interface MapPosition { latitude: number; longitude: number; @@ -386,6 +395,8 @@ export interface TextMessage { protocol?: MeshProtocol | 'meshtastic' | 'meshcore' | string; sender: TextMessageSender | null; sender_position?: MapPosition | null; + mc_sender_label?: string | null; + mc_sender_candidates?: McSenderCandidate[]; recipient_meshtastic_node_id: number | null; channel: number; sent_at: string; // ISO date string From 2ac7b536cac03c738e0fff0d352186cb07d9d10b Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 16:28:14 +0100 Subject: [PATCH 5/6] fix(messages): show feeder map when sender position unknown Overlay warning and draw listener markers without path lines. --- src/components/messages/HeardPathMap.tsx | 57 +++++++++++++++--------- src/components/messages/MessageItem.tsx | 6 ++- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/src/components/messages/HeardPathMap.tsx b/src/components/messages/HeardPathMap.tsx index ad92c87..dbef575 100644 --- a/src/components/messages/HeardPathMap.tsx +++ b/src/components/messages/HeardPathMap.tsx @@ -23,6 +23,8 @@ export type HeardPathLeg = { export type HeardPathMapProps = { sender: { label: string; position: MapPosition } | null; legs: HeardPathLeg[]; + /** Display name when sender position is missing (e.g. parsed MC channel prefix). */ + senderName?: string | null; }; function toLatLng(pos: MapPosition): LatLng { @@ -33,7 +35,7 @@ function hasPositionedWaypoints(waypoints: TracerouteRouteNode[]): boolean { return waypoints.some((node) => node.position != null); } -export function HeardPathMap({ sender, legs }: HeardPathMapProps) { +export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const tileLayerRef = useRef(null); @@ -75,17 +77,20 @@ export function HeardPathMap({ sender, legs }: HeardPathMapProps) { useEffect(() => { const map = mapInstanceRef.current; - if (!map || !senderPos) return; + if (!map || legs.length === 0) return; layersRef.current.forEach((layer) => layer.remove()); layersRef.current = []; const bounds = L.latLngBounds([]); - const senderMarker = L.marker(senderPos, { - icon: createNodeIcon(sender?.label ?? 'S', SENDER_COLOR, false), - }).addTo(map); - layersRef.current.push(senderMarker); - bounds.extend(senderPos); + + if (senderPos) { + const senderMarker = L.marker(senderPos, { + icon: createNodeIcon(sender?.label ?? 'S', SENDER_COLOR, false), + }).addTo(map); + layersRef.current.push(senderMarker); + bounds.extend(senderPos); + } legs.forEach((leg, index) => { const receiverPos = toLatLng(leg.receiver.position); @@ -96,6 +101,10 @@ export function HeardPathMap({ sender, legs }: HeardPathMapProps) { layersRef.current.push(receiverMarker); bounds.extend(receiverPos); + if (!senderPos) { + return; + } + const segments = leg.pathKnown && hasPositionedWaypoints(leg.waypoints) ? buildSegments(senderPos, leg.waypoints, receiverPos) @@ -150,27 +159,33 @@ export function HeardPathMap({ sender, legs }: HeardPathMapProps) { } }, [sender, senderPos, legs]); - if (!senderPos) { - return ( -
- No map — sender position unknown -
- ); - } - if (legs.length === 0) { return (
- No feeder positions available for map + {senderPos ? 'No feeder positions available for map' : 'No map — sender and feeder positions unknown'}
); } + const showSenderWarning = !senderPos; + const warningLabel = senderName?.trim() || sender?.label; + return ( -
+
+
+ {showSenderWarning && ( +
+ Sender position unknown + {warningLabel ? ` (${warningLabel})` : ''} — feeders shown; path lines omitted. +
+ )} +
); } diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index ef69755..fa5bb42 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -47,7 +47,11 @@ function HeardDialog({ Message Heard By - + {message.mc_sender_candidates && message.mc_sender_candidates.length > 1 && (

Multiple nodes match sender "{message.mc_sender_label}" — map uses feeder positions only. From 54b212869c5d49f43d98ce97ad2b35d17a90bad2 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Wed, 27 May 2026 16:31:26 +0100 Subject: [PATCH 6/6] feat(messages): show inferred MC sender in message header When mc_sender_candidates has exactly one node, show its name with a link instead of Anonymous; multiple matches show label plus badge; group consecutive messages by inferred sender key. --- docs/messages/current-features.md | 15 ++-- src/components/messages/MessageItem.tsx | 29 +++---- src/components/messages/MessageList.tsx | 11 ++- src/lib/message-display-sender.test.ts | 103 ++++++++++++++++++++++++ src/lib/message-display-sender.ts | 84 +++++++++++++++++++ 5 files changed, 219 insertions(+), 23 deletions(-) create mode 100644 src/lib/message-display-sender.test.ts create mode 100644 src/lib/message-display-sender.ts diff --git a/docs/messages/current-features.md b/docs/messages/current-features.md index 3d675a6..f139695 100644 --- a/docs/messages/current-features.md +++ b/docs/messages/current-features.md @@ -43,7 +43,9 @@ A **Discord-like** (non-bubble, dense, single-column) layout is **not** implemen ### Consecutive message grouping -- Same `sender.node_id_str`, within **15 minutes**, on the **main** timeline (not replies): rendered as one card. +- Same sender key, within **15 minutes**, on the **main** timeline (not replies): rendered as one card. +- **MT:** `sender.node_id_str`. +- **MC channel text:** single `mc_sender_candidates` entry → that node’s `node_id_str`; multiple candidates → `mc-ambiguous:{label}` (same parsed name only). - Primary message keeps full header; **continuations** appear below a divider with time + heard only (no repeated avatar row). ### Threading and reactions (Meshtastic-oriented) @@ -71,11 +73,14 @@ MeshCore may not use MT reply/emoji semantics; UI still runs the same grouping l - **MC:** `MeshCoreHeardObservation` — observer string (`node_id_str`), rx time, RSSI/SNR (no hop/direct badge). - Shown on main messages, continuations, and inline replies (smaller button variant). -### Sender links +### Sender display and links -- **MT** `!hex` senders: name links to `/nodes/{numericId}` (parsed from `node_id_str`). -- **Mobile:** extra external-link icon on small screens. -- **MC / anonymous:** label only, no node link (`parseNodeId` fails for non-hex ids). +- **MT** `!hex` senders: name links to node detail (parsed from `node_id_str`). +- **MC channel text** (`sender` null): inferred via API `mc_sender_label` / `mc_sender_candidates` (`messageSenderDisplay`): + - **0 candidates:** “Anonymous”, no link. + - **1 candidate:** node name + link to MeshCore node detail. + - **>1 candidates:** parsed label + “N matches” badge (tooltip lists candidate names); no link. +- **Mobile:** extra external-link icon on small screens when a link exists. ### Pagination diff --git a/src/components/messages/MessageItem.tsx b/src/components/messages/MessageItem.tsx index fa5bb42..24b7778 100644 --- a/src/components/messages/MessageItem.tsx +++ b/src/components/messages/MessageItem.tsx @@ -11,6 +11,7 @@ import { StaleReportedTime } from '@/components/nodes/StaleReportedTime'; import { Link } from 'react-router-dom'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { ExternalLink } from 'lucide-react'; +import { messageSenderDisplay } from '@/lib/message-display-sender'; import { nodeDetailPath } from '@/lib/node-detail-routes'; function HeardDialog({ @@ -157,15 +158,7 @@ export const MessageItem = memo(function MessageItem({ continuationMessages = [], }: MessageItemProps) { const proto = messageProtocol(message); - const senderDetailPath = useMemo(() => { - if (!message.sender?.node_id_str) { - return null; - } - return nodeDetailPath({ - node_id_str: message.sender.node_id_str, - protocol: proto === 'meshcore' ? 2 : 1, - }); - }, [message.sender, proto]); + const senderDisplay = useMemo(() => messageSenderDisplay(message, proto), [message, proto]); const fullTime = useMemo(() => { return message.sent_at ? format(new Date(message.sent_at), 'MMM d, yyyy h:mm a') : 'Unknown time'; }, [message.sent_at]); @@ -182,7 +175,7 @@ export const MessageItem = memo(function MessageItem({ return map; }, [emojiReactions]); - const senderName = message.sender?.short_name || message.sender?.node_id_str || 'Anonymous'; + const senderName = senderDisplay.name; return (

@@ -195,16 +188,17 @@ export const MessageItem = memo(function MessageItem({
{/* Desktop: sender name as link. Mobile: sender name + subtle icon link */} - {senderDetailPath != null ? ( + {senderDisplay.detailPath != null ? ( <> {senderName} @@ -212,7 +206,14 @@ export const MessageItem = memo(function MessageItem({ ) : ( - {senderName} + + {senderName} + + )} + {senderDisplay.ambiguous && ( + + {message.mc_sender_candidates?.length} matches + )}
{message.sent_at ? ( diff --git a/src/components/messages/MessageList.tsx b/src/components/messages/MessageList.tsx index c16b3fa..54ed6be 100644 --- a/src/components/messages/MessageList.tsx +++ b/src/components/messages/MessageList.tsx @@ -5,6 +5,8 @@ import { MessageItem } from './MessageItem'; import { Button } from '@/components/ui/button'; import type { TextMessage } from '@/lib/models'; import type { ProtocolSlug } from '@/lib/mesh-protocol'; +import { messageSenderGroupingKey } from '@/lib/message-display-sender'; +import { messageProtocol } from '@/lib/message-protocol'; const CONSECUTIVE_THRESHOLD_MINUTES = 15; @@ -59,10 +61,11 @@ export function MessageList({ channel, constellationId, nodeId, protocol }: Mess let j = i + 1; while (j < mainMessages.length) { const next = mainMessages[j]; - const sameSender = - next.sender?.node_id_str != null && - msg.sender?.node_id_str != null && - next.sender.node_id_str === msg.sender.node_id_str; + const msgProto = messageProtocol(msg); + const nextProto = messageProtocol(next); + const msgKey = messageSenderGroupingKey(msg, msgProto); + const nextKey = messageSenderGroupingKey(next, nextProto); + const sameSender = msgKey != null && nextKey != null && msgKey === nextKey; const withinWindow = msg.sent_at && next.sent_at && diff --git a/src/lib/message-display-sender.test.ts b/src/lib/message-display-sender.test.ts new file mode 100644 index 0000000..ed98b2c --- /dev/null +++ b/src/lib/message-display-sender.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { messageSenderDisplay, messageSenderGroupingKey } from './message-display-sender'; +import type { TextMessage } from '@/lib/models'; + +const baseMessage: TextMessage = { + id: '1', + packet_id: 1, + channel: 1, + sent_at: new Date().toISOString(), + message_text: 'WMF: hello', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + recipient_meshtastic_node_id: null, + sender: null, + heard: [], +}; + +describe('messageSenderDisplay', () => { + it('uses single MC candidate instead of Anonymous', () => { + const display = messageSenderDisplay( + { + ...baseMessage, + protocol: 'meshcore', + mc_sender_label: 'WMF', + mc_sender_candidates: [ + { + internal_id: '00000000-0000-4000-8000-000000000001', + node_id_str: 'mc:wmf', + long_name: 'West Midlands', + short_name: 'WMF', + position: null, + }, + ], + }, + 'meshcore' + ); + expect(display.name).toBe('WMF'); + expect(display.detailPath).toBeTruthy(); + expect(display.ambiguous).toBeUndefined(); + }); + + it('shows Anonymous when no MC candidates', () => { + const display = messageSenderDisplay( + { ...baseMessage, protocol: 'meshcore', mc_sender_label: 'WMF', mc_sender_candidates: [] }, + 'meshcore' + ); + expect(display.name).toBe('Anonymous'); + expect(display.detailPath).toBeNull(); + }); + + it('marks ambiguous when multiple candidates', () => { + const display = messageSenderDisplay( + { + ...baseMessage, + protocol: 'meshcore', + mc_sender_label: 'WMF', + mc_sender_candidates: [ + { + internal_id: '1', + node_id_str: 'mc:a', + long_name: 'WMF', + short_name: 'A', + position: null, + }, + { + internal_id: '2', + node_id_str: 'mc:b', + long_name: 'WMF', + short_name: 'B', + position: null, + }, + ], + }, + 'meshcore' + ); + expect(display.name).toBe('WMF'); + expect(display.ambiguous).toBe(true); + expect(display.detailPath).toBeNull(); + expect(display.title).toContain('2 nodes'); + }); +}); + +describe('messageSenderGroupingKey', () => { + it('groups by candidate node_id_str when one match', () => { + const key = messageSenderGroupingKey( + { + ...baseMessage, + protocol: 'meshcore', + mc_sender_candidates: [ + { + internal_id: '1', + node_id_str: 'mc:wmf', + long_name: 'WMF', + short_name: 'WMF', + position: null, + }, + ], + }, + 'meshcore' + ); + expect(key).toBe('mc:wmf'); + }); +}); diff --git a/src/lib/message-display-sender.ts b/src/lib/message-display-sender.ts new file mode 100644 index 0000000..0de2a6f --- /dev/null +++ b/src/lib/message-display-sender.ts @@ -0,0 +1,84 @@ +import type { McSenderCandidate, TextMessage } from '@/lib/models'; +import { nodeDetailPath } from '@/lib/node-detail-routes'; +import type { MessageProtocolSlug } from '@/lib/message-protocol'; + +export type MessageSenderDisplay = { + name: string; + detailPath: string | null; + /** True when multiple ObservedNodes match the parsed channel prefix. */ + ambiguous?: boolean; + /** Tooltip for ambiguous senders (list of candidate nodes). */ + title?: string; +}; + +function candidateDisplayName(candidate: McSenderCandidate): string { + return candidate.short_name || candidate.long_name || candidate.node_id_str; +} + +function candidateDetailPath(candidate: McSenderCandidate): string | null { + return nodeDetailPath({ + internal_id: candidate.internal_id, + node_id_str: candidate.node_id_str, + protocol: 2, + }); +} + +/** Header label + optional node link for a message row. */ +export function messageSenderDisplay(message: TextMessage, proto: MessageProtocolSlug): MessageSenderDisplay { + if (message.sender?.node_id_str) { + const name = message.sender.short_name || message.sender.long_name || message.sender.node_id_str; + return { + name, + detailPath: nodeDetailPath({ + node_id_str: message.sender.node_id_str, + protocol: proto === 'meshcore' ? 2 : 1, + }), + }; + } + + if (proto !== 'meshcore') { + return { name: 'Anonymous', detailPath: null }; + } + + const candidates = message.mc_sender_candidates ?? []; + const label = message.mc_sender_label?.trim(); + + if (candidates.length === 1) { + const candidate = candidates[0]; + return { + name: candidateDisplayName(candidate), + detailPath: candidateDetailPath(candidate), + }; + } + + if (candidates.length > 1) { + const heading = label || candidateDisplayName(candidates[0]); + const names = candidates.map(candidateDisplayName).join(', '); + return { + name: heading, + detailPath: null, + ambiguous: true, + title: `${candidates.length} nodes named "${heading}": ${names}`, + }; + } + + return { name: 'Anonymous', detailPath: null }; +} + +/** Key for grouping consecutive messages from the same sender. */ +export function messageSenderGroupingKey(message: TextMessage, proto: MessageProtocolSlug): string | null { + if (message.sender?.node_id_str) { + return message.sender.node_id_str; + } + if (proto !== 'meshcore') { + return null; + } + const candidates = message.mc_sender_candidates ?? []; + if (candidates.length === 1) { + return candidates[0].node_id_str; + } + if (candidates.length > 1 && message.mc_sender_label) { + return `mc-ambiguous:${message.mc_sender_label.toLowerCase()}`; + } + return null; +}