Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions docs/messages/current-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
191 changes: 191 additions & 0 deletions src/components/messages/HeardPathMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
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';
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[];
/** Display name when sender position is missing (e.g. parsed MC channel prefix). */
senderName?: string | null;
};

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, senderName }: HeardPathMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const tileLayerRef = useRef<L.TileLayer | null>(null);
const layersRef = useRef<L.Layer[]>([]);
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_NODE_MARKER_CSS;
document.head.appendChild(style);

return () => {
map.remove();
mapInstanceRef.current = null;
tileLayerRef.current = null;
style.remove();
};
}
}, []);

Check warning on line 66 in src/components/messages/HeardPathMap.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook useEffect has missing dependencies: 'attribution' and 'tileUrl'. Either include them or remove the dependency array

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 || legs.length === 0) return;

layersRef.current.forEach((layer) => layer.remove());
layersRef.current = [];

const bounds = L.latLngBounds([]);

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);
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);

if (!senderPos) {
return;
}

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 });
}
map.invalidateSize();
}, 150);
return () => clearTimeout(t);
}
}, [sender, senderPos, legs]);

if (legs.length === 0) {
return (
<div className="flex min-h-[200px] items-center justify-center rounded-md border bg-muted/30 text-sm text-muted-foreground">
{senderPos ? 'No feeder positions available for map' : 'No map — sender and feeder positions unknown'}
</div>
);
}

const showSenderWarning = !senderPos;
const warningLabel = senderName?.trim() || sender?.label;

return (
<div className="relative rounded-md border" style={{ height: '280px' }}>
<div
ref={mapRef}
style={{ height: '100%', position: 'relative', zIndex: 1 }}
className="map-container rounded-md"
/>
{showSenderWarning && (
<div
className="pointer-events-none absolute left-2 right-2 top-2 z-[1000] rounded-md border border-amber-500/60 bg-amber-50/95 px-3 py-2 text-xs text-amber-950 shadow-sm dark:border-amber-600/50 dark:bg-amber-950/90 dark:text-amber-50"
role="status"
>
Sender position unknown
{warningLabel ? ` (${warningLabel})` : ''} — feeders shown; path lines omitted.
</div>
)}
</div>
);
}
Loading
Loading