Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history

This commit is contained in:
Gnome Adrift
2026-03-31 09:11:49 -07:00
86 changed files with 4450 additions and 724 deletions
+3
View File
@@ -274,6 +274,7 @@ export function App() {
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
@@ -349,6 +350,7 @@ export function App() {
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -457,6 +459,7 @@ export function App() {
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onRunTracePath: api.requestRadioTrace,
onPathDiscovery: handlePathDiscovery,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
+11 -2
View File
@@ -20,6 +20,8 @@ import type {
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
RadioTraceHopRequest,
RadioTraceResponse,
RadioDiscoveryTarget,
PathDiscoveryResponse,
ResendChannelMessageResponse,
@@ -108,6 +110,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ target }),
}),
requestRadioTrace: (hopHashBytes: 1 | 2 | 4, hops: RadioTraceHopRequest[]) =>
fetchJson<RadioTraceResponse>('/radio/trace', {
method: 'POST',
body: JSON.stringify({ hop_hash_bytes: hopHashBytes, hops }),
}),
rebootRadio: () =>
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
@@ -131,11 +138,13 @@ export const api = {
fetchJson<ContactAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
getContactAnalytics: (params: { publicKey?: string; name?: string }, signal?: AbortSignal) => {
const searchParams = new URLSearchParams();
if (params.publicKey) searchParams.set('public_key', params.publicKey);
if (params.name) searchParams.set('name', params.name);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`, {
signal,
});
},
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
+1 -1
View File
@@ -268,7 +268,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
: 'Direct Trace. Send a direct trace probe to this contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
+119 -129
View File
@@ -1,6 +1,16 @@
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { api } from '../api';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { api, isAbortError } from '../api';
import { formatTime } from '../utils/messageParser';
import {
getContactDisplayName,
@@ -100,29 +110,29 @@ export function ContactInfoPane({
return;
}
let cancelled = false;
const controller = new AbortController();
setAnalytics(null);
setLoading(true);
const request =
isNameOnly && nameOnlyValue
? api.getContactAnalytics({ name: nameOnlyValue })
: api.getContactAnalytics({ publicKey: contactKey });
? api.getContactAnalytics({ name: nameOnlyValue }, controller.signal)
: api.getContactAnalytics({ publicKey: contactKey }, controller.signal);
request
.then((data) => {
if (!cancelled) setAnalytics(data);
if (!controller.signal.aborted) setAnalytics(data);
})
.catch((err) => {
if (!cancelled) {
if (!isAbortError(err)) {
console.error('Failed to fetch contact analytics:', err);
toast.error('Failed to load contact info');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
if (!controller.signal.aborted) setLoading(false);
});
return () => {
cancelled = true;
controller.abort();
};
}, [contactKey, isNameOnly, nameOnlyValue]);
@@ -650,20 +660,18 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
{hasHourlyActivity && (
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ChartLegend
items={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
/>
<ActivityLineChart
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
{ key: 'last_24h_count', color: '#2563eb', label: 'Last 24h' },
{ key: 'last_week_average', color: '#ea580c', label: '7-day avg' },
{ key: 'all_time_average', color: '#64748b', label: 'All-time avg' },
]}
legendItems={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
tickFormatter={(bucket) =>
@@ -683,7 +691,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
series={[{ key: 'message_count', color: '#16a34a', label: 'Messages' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
@@ -705,133 +713,115 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
legendItems,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string }>;
series: Array<{ key: keyof T; color: string; label?: string }>;
legendItems?: Array<{ label: string; color: string }>;
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((entry) => {
const value = point[entry.key];
return typeof value === 'number' ? value : 0;
})
);
const maxValue = Math.max(1, ...allValues);
const tickIndices = Array.from(
new Set([
0,
Math.floor((points.length - 1) / 3),
Math.floor(((points.length - 1) * 2) / 3),
points.length - 1,
])
);
const data = points.map((point, i) => {
const entry: Record<string, string | number> = { idx: i, tick: tickFormatter(point) };
for (const s of series) {
const raw = point[s.key];
entry[String(s.key)] = typeof raw === 'number' ? raw : 0;
}
return entry;
});
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
return `${x},${y}`;
})
.join(' ');
const tickCount = Math.min(5, points.length);
const tickIndices: number[] = [];
if (points.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (points.length - 1)));
}
}
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<text
x={padding.left - 6}
y={y + 4}
fontSize="10"
textAnchor="end"
fill="hsl(var(--muted-foreground))"
>
{valueFormatter(value)}
</text>
</g>
);
})}
{series.map((entry) => (
<polyline
key={String(entry.key)}
fill="none"
stroke={entry.color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={buildPolyline(entry.key)}
<div role="img" aria-label={ariaLabel}>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -16 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, Math.max(1, points.length - 1)]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => String(data[idx]?.tick ?? '')}
/>
))}
{tickIndices.map((index) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => valueFormatter(v)}
width={40}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => String(data[Number(idx)]?.tick ?? '')}
formatter={(value, name) => {
const match = series.find((s) => String(s.key) === name);
return [valueFormatter(Number(value)), match?.label ?? String(name)];
}}
/>
{legendItems && (
<Legend
content={() => (
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
{legendItems.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
/>
{item.label}
</span>
))}
</div>
)}
/>
)}
{series.map((entry) => (
<Line
key={String(entry.key)}
type="linear"
dataKey={String(entry.key)}
stroke={entry.color}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
);
}
@@ -5,6 +5,7 @@ import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketFeedView } from './RawPacketFeedView';
import { RoomServerPanel } from './RoomServerPanel';
import { TracePane } from './TracePane';
import type {
Channel,
Contact,
@@ -15,6 +16,8 @@ import type {
PathDiscoveryResponse,
RawPacket,
RadioConfig,
RadioTraceHopRequest,
RadioTraceResponse,
} from '../types';
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
@@ -50,6 +53,10 @@ interface ConversationPaneProps {
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onRunTracePath: (
hopHashBytes: 1 | 2 | 4,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
@@ -115,6 +122,7 @@ export function ConversationPane({
loadingNewer,
messageInputRef,
onTrace,
onRunTracePath,
onPathDiscovery,
onToggleFavorite,
onDeleteContact,
@@ -200,6 +208,10 @@ export function ConversationPane({
return null;
}
if (activeConversation.type === 'trace') {
return <TracePane contacts={contacts} config={config} onRunTracePath={onRunTracePath} />;
}
if (activeContactIsRepeater) {
return (
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
+3 -2
View File
@@ -128,8 +128,9 @@ export function CrackerPanel({
}, [existingChannelKeys]);
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
const undecryptedGroupText = useMemo(
() => packets.filter((p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted),
[packets]
);
// Update queue when packets change (deduplicated by payload)
+14
View File
@@ -131,9 +131,23 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
if (ref === null) {
delete markerRefs.current[key];
return;
}
markerRefs.current[key] = ref;
}, []);
useEffect(() => {
const currentKeys = new Set(mappableContacts.map((contact) => contact.public_key));
for (const key of Object.keys(markerRefs.current)) {
if (!currentKeys.has(key)) {
delete markerRefs.current[key];
}
}
}, [mappableContacts]);
// Open popup for focused contact after map is ready
useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) {
+16 -1
View File
@@ -373,7 +373,22 @@ export function MessageList({
}
}
setResendableIds(newResendable);
setResendableIds((prev) => {
if (prev.size === newResendable.size) {
let changed = false;
for (const id of newResendable) {
if (!prev.has(id)) {
changed = true;
break;
}
}
if (!changed) {
return prev;
}
}
return newResendable;
});
return () => {
for (const timer of timers.values()) clearTimeout(timer);
+4 -3
View File
@@ -81,13 +81,14 @@ export function PathModal({
) : hasSinglePath ? (
<>
This shows <em>one route</em> that this message traveled through the mesh network.
Repeaters may be incorrectly identified due to prefix collisions between heard and
non-heard repeater advertisements.
Repeater identities are inferred from locally known advert and path data, so some
hops may be missing or misidentified when that data is incomplete.
</>
) : (
<>
This message was received via <strong>{paths.length} different routes</strong>.
Repeaters may be incorrectly identified due to prefix collisions.
Repeater identities are inferred from locally known advert and path data, so some
hops may be missing or misidentified when that data is incomplete.
</>
)}
</DialogDescription>
+105 -59
View File
@@ -1,5 +1,15 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
Cell,
} from 'recharts';
import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
@@ -24,6 +34,18 @@ interface RawPacketFeedViewProps {
channels: Channel[];
}
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
'1m': '1 min',
'5m': '5 min',
@@ -32,13 +54,7 @@ const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
session: 'Session',
};
const TIMELINE_COLORS = [
'bg-sky-500/80',
'bg-emerald-500/80',
'bg-amber-500/80',
'bg-rose-500/80',
'bg-violet-500/80',
];
const TIMELINE_FILL_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
function formatTimestamp(timestampMs: number): string {
return new Date(timestampMs).toLocaleString([], {
@@ -220,7 +236,13 @@ function RankedBars({
emptyLabel: string;
formatter?: (item: RankedPacketStat) => string;
}) {
const maxCount = Math.max(...items.map((item) => item.count), 1);
const data = items.map((item) => ({
name: item.label,
value: item.count,
detail: formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`,
}));
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
@@ -228,25 +250,36 @@ function RankedBars({
{items.length === 0 ? (
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
) : (
<div className="mt-3 space-y-2">
{items.map((item) => (
<div key={item.label}>
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
<span className="truncate text-foreground">{item.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{formatter
? formatter(item)
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary/80"
style={{ width: `${(item.count / maxCount) * 100}%` }}
/>
</div>
</div>
))}
<div className="mt-2">
<ResponsiveContainer width="100%" height={items.length * 28 + 8}>
<BarChart
data={data}
layout="vertical"
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
barCategoryGap="20%"
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={80}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(_v: any, _n: any, props: any) => [props.payload.detail, null]}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={16}>
{data.map((_, i) => (
<Cell key={i} fill={TIMELINE_FILL_COLORS[i % TIMELINE_FILL_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
)}
</section>
@@ -320,53 +353,66 @@ function NeighborList({
}
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
0,
TIMELINE_COLORS.length
TIMELINE_FILL_COLORS.length
);
const data = bins.map((bin) => {
const entry: Record<string, string | number> = { label: bin.label };
for (const type of typeOrder) {
entry[type] = bin.countsByType[type] ?? 0;
}
return entry;
});
return (
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, index) => (
{typeOrder.map((type, i) => (
<span key={type} className="inline-flex items-center gap-1">
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: TIMELINE_FILL_COLORS[i] }}
/>
<span>{type}</span>
</span>
))}
</div>
</div>
<div className="mt-3 flex items-start gap-1">
{bins.map((bin, index) => (
<div
key={`${bin.label}-${index}`}
className="flex min-w-0 flex-1 flex-col items-center gap-1"
>
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
<div className="flex h-full w-full flex-col justify-end">
{typeOrder.map((type, index) => {
const count = bin.countsByType[type] ?? 0;
if (count === 0) return null;
return (
<div
key={type}
className={cn('w-full', TIMELINE_COLORS[index])}
style={{
height: `${(count / maxTotal) * 100}%`,
}}
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
/>
);
})}
</div>
</div>
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
</div>
))}
<div className="mt-2">
<ResponsiveContainer width="100%" height={110}>
<BarChart data={data} margin={{ top: 4, right: 0, bottom: 0, left: -24 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
/>
{typeOrder.map((type, i) => (
<Bar
key={type}
dataKey={type}
stackId="packets"
fill={TIMELINE_FILL_COLORS[i]}
radius={i === typeOrder.length - 1 ? [2, 2, 0, 0] : undefined}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</section>
);
+5 -1
View File
@@ -174,7 +174,11 @@ export function SearchView({
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.then((data) => {
setResults((prev) => [...prev, ...(data as SearchResult[])]);
setResults((prev) => {
const existingIds = new Set(prev.map((r) => r.id));
const unique = (data as SearchResult[]).filter((r) => !existingIds.has(r.id));
return [...prev, ...unique];
});
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset((prev) => prev + data.length);
})
+16 -3
View File
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
Cable,
ChartNetwork,
CheckCheck,
ChevronDown,
ChevronRight,
@@ -9,7 +11,6 @@ import {
Map,
Search as SearchIcon,
SquarePen,
Waypoints,
X,
} from 'lucide-react';
import {
@@ -197,7 +198,7 @@ export function Sidebar({
};
const isActive = (
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace',
id: string
) => activeConversation?.type === type && activeConversation?.id === id;
@@ -721,7 +722,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: <Waypoints className="h-4 w-4" />,
icon: <ChartNetwork className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -730,6 +731,18 @@ export function Sidebar({
name: 'Mesh Visualizer',
}),
}),
renderSidebarActionRow({
key: 'tool-trace',
active: isActive('trace', 'trace'),
icon: <Cable className="h-4 w-4" />,
label: 'Trace',
onClick: () =>
handleSelectConversation({
type: 'trace',
id: 'trace',
name: 'Trace',
}),
}),
renderSidebarActionRow({
key: 'tool-search',
active: isActive('search', 'search'),
+691
View File
@@ -0,0 +1,691 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { ArrowDown, ArrowUp, Plus, X } from 'lucide-react';
import type {
Contact,
RadioConfig,
RadioTraceHopRequest,
RadioTraceNode,
RadioTraceResponse,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { calculateDistance, isValidLocation } from '../utils/pathUtils';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Input } from './ui/input';
import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4;
type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
interface TracePaneProps {
contacts: Contact[];
config: RadioConfig | null;
onRunTracePath: (
hopHashBytes: CustomHopBytes,
hops: RadioTraceHopRequest[]
) => Promise<RadioTraceResponse>;
}
function getHeardTimestamp(contact: Contact): number {
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
}
function getDistanceKm(contact: Contact, config: RadioConfig | null): number | null {
if (
!config ||
!isValidLocation(config.lat, config.lon) ||
!isValidLocation(contact.lat, contact.lon)
) {
return null;
}
return calculateDistance(config.lat, config.lon, contact.lat, contact.lon);
}
function getShortKey(publicKey: string | null | undefined): string {
if (!publicKey) return 'unknown';
return publicKey.slice(0, 12);
}
function formatSNR(snr: number | null | undefined): string {
if (typeof snr !== 'number' || Number.isNaN(snr)) {
return '—';
}
return `${snr >= 0 ? '+' : ''}${snr.toFixed(1)} dB`;
}
function moveHop(hops: TraceDraftHop[], index: number, direction: -1 | 1): TraceDraftHop[] {
const nextIndex = index + direction;
if (nextIndex < 0 || nextIndex >= hops.length) {
return hops;
}
const next = [...hops];
const [item] = next.splice(index, 1);
next.splice(nextIndex, 0, item);
return next;
}
function normalizeCustomHopHex(value: string): string {
return value.replace(/[^a-fA-F0-9]/g, '').toLowerCase();
}
function nextDraftHopId(prefix: string, currentLength: number): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${currentLength}`;
}
function TraceNodeRow({
title,
subtitle,
meta,
note,
fixed = false,
compact = false,
actions,
snr,
}: {
title: string;
subtitle: string;
meta?: string | null;
note?: string | null;
fixed?: boolean;
compact?: boolean;
actions?: ReactNode;
snr?: string | null;
}) {
return (
<div
className={cn(
'flex items-center rounded-md border border-border bg-background',
compact ? 'gap-2 px-2.5 py-2' : 'gap-3 px-3 py-3'
)}
>
<div
className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
fixed
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground'
)}
>
{fixed ? 'Self' : 'Hop'}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
</div>
{snr ? (
<div className="shrink-0 text-right">
<div className="text-[11px] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div>
</div>
) : null}
{actions ? <div className="ml-1 flex items-center gap-1">{actions}</div> : null}
</div>
);
}
export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortMode, setSortMode] = useState<TraceSortMode>('alpha');
const [draftHops, setDraftHops] = useState<TraceDraftHop[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RadioTraceResponse | null>(null);
const [customDialogOpen, setCustomDialogOpen] = useState(false);
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null);
const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => {
const deduped = new Map<string, Contact>();
for (const contact of contacts) {
if (contact.type !== CONTACT_TYPE_REPEATER || contact.public_key.length !== 64) {
continue;
}
if (!deduped.has(contact.public_key)) {
deduped.set(contact.public_key, contact);
}
}
return [...deduped.values()];
}, [contacts]);
const repeatersByKey = useMemo(
() => new Map(repeaters.map((contact) => [contact.public_key, contact])),
[repeaters]
);
const filteredRepeaters = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
const matching = query
? repeaters.filter(
(contact) =>
contact.public_key.toLowerCase().includes(query) ||
(contact.name ?? '').toLowerCase().includes(query)
)
: repeaters;
return [...matching].sort((left, right) => {
if (sortMode === 'recent') {
const leftTs = getHeardTimestamp(left);
const rightTs = getHeardTimestamp(right);
if (leftTs !== rightTs) {
return rightTs - leftTs;
}
}
if (sortMode === 'distance') {
const leftDistance = getDistanceKm(left, config);
const rightDistance = getDistanceKm(right, config);
if (leftDistance !== null && rightDistance !== null && leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
if (leftDistance !== null && rightDistance === null) return -1;
if (leftDistance === null && rightDistance !== null) return 1;
}
return getContactDisplayName(left.name, left.public_key, left.last_advert).localeCompare(
getContactDisplayName(right.name, right.public_key, right.last_advert)
);
});
}, [config, repeaters, searchQuery, sortMode]);
const localRadioName = config?.name || 'Local radio';
const localRadioKey = config?.public_key ?? null;
const canSortByDistance = !!config && isValidLocation(config.lat, config.lon);
const customHopBytesLocked = useMemo(
() => draftHops.find((hop) => hop.kind === 'custom')?.hopBytes ?? null,
[draftHops]
);
const effectiveHopHashBytes: CustomHopBytes = customHopBytesLocked ?? 4;
useEffect(() => {
if (!customDialogOpen) return;
setCustomHopBytesDraft(customHopBytesLocked ?? 1);
setCustomHopHexDraft('');
setCustomHopError(null);
}, [customDialogOpen, customHopBytesLocked]);
const clearPendingResult = () => {
activeRunTokenRef.current += 1;
setLoading(false);
if (result) setResult(null);
if (error) setError(null);
};
const handleAddRepeater = (publicKey: string) => {
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('repeater', current.length),
kind: 'repeater',
publicKey,
},
]);
clearPendingResult();
};
const handleAddCustomHop = () => {
const hopBytes = customHopBytesLocked ?? customHopBytesDraft;
const hopHex = normalizeCustomHopHex(customHopHexDraft);
if (hopHex.length !== hopBytes * 2) {
setCustomHopError(`Custom hop must be exactly ${hopBytes * 2} hex characters.`);
return;
}
setDraftHops((current) => [
...current,
{
id: nextDraftHopId('custom', current.length),
kind: 'custom',
hopHex,
hopBytes,
},
]);
clearPendingResult();
setCustomDialogOpen(false);
};
const handleRemoveHop = (id: string) => {
setDraftHops((current) => current.filter((hop) => hop.id !== id));
clearPendingResult();
};
const handleMoveHop = (index: number, direction: -1 | 1) => {
setDraftHops((current) => moveHop(current, index, direction));
clearPendingResult();
};
const handleRunTrace = async () => {
if (draftHops.length === 0) {
return;
}
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
effectiveHopHashBytes,
draftHops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) {
return;
}
setResult(traceResult);
} catch (err) {
if (activeRunTokenRef.current !== runToken) {
return;
}
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) {
setLoading(false);
}
}
};
const resultNodes: RadioTraceNode[] = result
? [
{
role: 'local',
public_key: localRadioKey,
name: localRadioName,
observed_hash: null,
snr: null,
},
...result.nodes,
]
: [];
return (
<div className="flex h-full min-h-0 flex-col overflow-y-auto">
<div className="border-b border-border px-4 py-3">
<h2 className="text-base font-semibold">Trace</h2>
<p className="mt-1 max-w-3xl text-sm text-muted-foreground">
Build a repeater loop and trace it back to the local radio. The selectable hop list only
includes known full-key repeaters, but you can also add custom repeater prefixes.
</p>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 lg:min-h-0 lg:flex-row lg:overflow-hidden">
<section className="flex w-full flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:max-w-[24rem]">
<div className="border-b border-border p-4">
<h3 className="text-sm font-semibold">Repeater Hops</h3>
<p className="mt-1 text-xs text-muted-foreground">
Search by name or key, then add repeaters in the order you want to traverse them.
</p>
<Button
type="button"
size="sm"
variant="outline"
className="mt-3"
onClick={() => setCustomDialogOpen(true)}
>
Custom path
</Button>
<Input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder="Search name or public key"
aria-label="Search repeaters"
className="mt-3"
/>
<div className="mt-3 flex flex-wrap gap-2">
{(
[
['alpha', 'Alpha'],
['recent', 'Recent Heard'],
['distance', 'Distance'],
] as const
).map(([value, label]) => (
<Button
key={value}
type="button"
size="sm"
variant={sortMode === value ? 'default' : 'outline'}
onClick={() => setSortMode(value)}
>
{label}
</Button>
))}
</div>
{sortMode === 'distance' && !canSortByDistance ? (
<p className="mt-2 text-[11px] text-muted-foreground">
Distance sorting is using known repeater coordinates, but the local radio does not
currently have a valid location.
</p>
) : null}
</div>
<div className="max-h-[40vh] overflow-y-auto p-2 lg:min-h-0 lg:max-h-none lg:flex-1">
{filteredRepeaters.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-3 py-6 text-center text-sm text-muted-foreground">
No repeaters matched this search.
</div>
) : (
<div className="space-y-2">
{filteredRepeaters.map((contact) => {
const displayName = getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
);
const distanceKm = getDistanceKm(contact, config);
const selectedCount = draftHops.filter(
(hop) => hop.kind === 'repeater' && hop.publicKey === contact.public_key
).length;
return (
<div
key={contact.public_key}
role="button"
tabIndex={0}
aria-label={`Add repeater ${displayName}`}
className={cn(
'flex w-full items-center gap-3 rounded-md border px-3 py-3 text-left transition-colors',
selectedCount > 0
? 'border-primary/30 bg-primary/5'
: 'border-border bg-background hover:bg-accent'
)}
onClick={() => handleAddRepeater(contact.public_key)}
onKeyDown={handleKeyboardActivate}
>
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={28}
contactType={contact.type}
/>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{displayName}</div>
<div className="truncate text-xs text-muted-foreground">
{getShortKey(contact.public_key)}
</div>
{sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[11px] text-muted-foreground">
{distanceKm.toFixed(1)} km away
</div>
) : null}
{selectedCount > 0 ? (
<div className="mt-1 text-[11px] text-muted-foreground">
Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
</div>
) : null}
</div>
<span
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md border border-input bg-background text-muted-foreground"
aria-hidden="true"
>
<Plus className="h-4 w-4" />
</span>
</div>
);
})}
</div>
)}
</div>
</section>
<section className="flex flex-1 flex-col gap-4 lg:min-h-0 lg:overflow-hidden">
<div className="rounded-lg border border-border bg-card">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">Trace Path</h3>
<p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio.
</p>
</div>
<div className="max-h-[42vh] space-y-2 overflow-y-auto p-4 lg:max-h-none lg:overflow-y-visible">
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Origin"
fixed
compact
/>
{draftHops.length === 0 ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Add at least one hop to build a trace loop.
</div>
) : (
draftHops.map((hop, index) => {
const contact =
hop.kind === 'repeater' ? (repeatersByKey.get(hop.publicKey) ?? null) : null;
const displayName =
hop.kind === 'repeater'
? getContactDisplayName(
contact?.name,
hop.publicKey,
contact?.last_advert ?? null
)
: 'Custom hop';
const subtitle =
hop.kind === 'repeater'
? getShortKey(hop.publicKey)
: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}-byte)`;
return (
<div key={hop.id}>
<TraceNodeRow
title={displayName}
subtitle={subtitle}
meta={`Hop ${index + 1}`}
note={
index === draftHops.length - 1
? 'Note: you must be able to hear the final repeater in the trace for trace success.'
: null
}
compact
actions={
<>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} up`}
onClick={() => handleMoveHop(index, -1)}
disabled={index === 0}
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Move ${displayName} down`}
onClick={() => handleMoveHop(index, 1)}
disabled={index === draftHops.length - 1}
>
<ArrowDown className="h-4 w-4" />
</Button>
<Button
type="button"
size="icon"
variant="outline"
className="h-8 w-8"
aria-label={`Remove ${displayName}`}
onClick={() => handleRemoveHop(hop.id)}
>
<X className="h-4 w-4" />
</Button>
</>
}
/>
</div>
);
})
)}
<TraceNodeRow
title={localRadioName}
subtitle={getShortKey(localRadioKey)}
meta="Terminal"
fixed
compact
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-3 border-t border-border px-4 py-3">
<div className="text-xs text-muted-foreground">
{draftHops.length === 0
? 'No hops selected'
: `${draftHops.length} hop${draftHops.length === 1 ? '' : 's'} selected · ${effectiveHopHashBytes}-byte trace`}
</div>
<Button onClick={handleRunTrace} disabled={loading || draftHops.length === 0}>
{loading ? 'Tracing...' : 'Send trace'}
</Button>
</div>
</div>
<div className="flex flex-col rounded-lg border border-border bg-card lg:min-h-0 lg:flex-1">
<div className="border-b border-border px-4 py-3">
<h3 className="text-sm font-semibold">
Results{result ? ` (${result.timeout_seconds.toFixed(1)}s)` : ''}
</h3>
</div>
<div className="max-h-[42vh] min-h-0 flex-1 space-y-3 overflow-y-auto p-4 lg:max-h-none">
{error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
) : null}
{!error && !result ? (
<div className="rounded-md border border-dashed border-border px-4 py-6 text-sm text-muted-foreground">
Send a trace to see the returned hop-by-hop SNR values.
</div>
) : null}
{result
? resultNodes.map((node, index) => {
const title =
node.name ||
(node.role === 'custom'
? 'Custom hop'
: node.role === 'local'
? localRadioName
: getShortKey(node.public_key));
const subtitle =
node.role === 'custom'
? `Key prefix ${node.observed_hash?.toUpperCase() ?? 'unknown'}`
: node.observed_hash &&
node.public_key &&
node.observed_hash.toLowerCase() !==
getShortKey(node.public_key).toLowerCase()
? `${getShortKey(node.public_key)} · key prefix ${node.observed_hash.toUpperCase()}`
: getShortKey(node.public_key);
return (
<div
key={`${node.role}-${node.public_key ?? node.observed_hash ?? 'local'}-${index}`}
>
<TraceNodeRow
title={title}
subtitle={subtitle}
meta={
index === 0
? 'Origin'
: node.role === 'local'
? 'Terminal'
: `Hop ${index}`
}
fixed={node.role === 'local'}
snr={index === 0 ? null : formatSNR(node.snr)}
/>
</div>
);
})
: null}
</div>
</div>
</section>
</div>
<Dialog open={customDialogOpen} onOpenChange={setCustomDialogOpen}>
<DialogContent className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle>Custom path hop</DialogTitle>
<DialogDescription>
Add a raw repeater prefix as a 1-byte, 2-byte, or 4-byte hop. Once you add a custom
hop, all later custom hops must use the same byte width.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Hop width</div>
<div className="flex flex-wrap gap-2">
{([1, 2, 4] as const).map((value) => {
const locked = customHopBytesLocked !== null && customHopBytesLocked !== value;
const active = (customHopBytesLocked ?? customHopBytesDraft) === value;
return (
<Button
key={value}
type="button"
size="sm"
variant={active ? 'default' : 'outline'}
disabled={locked}
onClick={() => setCustomHopBytesDraft(value)}
>
{value}-byte
</Button>
);
})}
</div>
{customHopBytesLocked !== null ? (
<p className="text-xs text-muted-foreground">
Custom hops are locked to {customHopBytesLocked}-byte prefixes for this trace.
</p>
) : null}
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="custom-hop-hex">
Repeater prefix
</label>
<Input
id="custom-hop-hex"
value={customHopHexDraft}
onChange={(event) =>
setCustomHopHexDraft(normalizeCustomHopHex(event.target.value))
}
placeholder={`${(customHopBytesLocked ?? customHopBytesDraft) * 2} hex chars`}
/>
<p className="text-xs text-muted-foreground">
Enter exactly {(customHopBytesLocked ?? customHopBytesDraft) * 2} hex characters.
</p>
{customHopError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{customHopError}
</div>
) : null}
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button type="button" variant="secondary" onClick={() => setCustomDialogOpen(false)}>
Cancel
</Button>
<Button type="button" onClick={handleAddCustomHop}>
Add custom hop
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -17,6 +17,14 @@ import {
setSavedDistanceUnit,
} from '../../utils/distanceUnits';
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_SLIDER_STEP,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../../utils/fontScale';
export function SettingsLocalSection({
onLocalLabelChange,
@@ -31,6 +39,29 @@ export function SettingsLocalSection({
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
const commitFontScale = (nextScale: number) => {
const normalized = setSavedFontScale(nextScale);
setFontScale(normalized);
setFontScaleSlider(normalized);
setFontScaleInput(String(normalized));
};
const restoreFontScaleInput = () => {
setFontScaleInput(String(fontScale));
};
const handleSliderChange = (nextScale: number) => {
setFontScaleSlider(nextScale);
setFontScaleInput(String(nextScale));
};
const handleSliderCommit = (nextScale: number) => {
commitFontScale(nextScale);
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
@@ -89,6 +120,85 @@ export function SettingsLocalSection({
<Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label>
<select
@@ -846,11 +846,16 @@ export function SettingsRadioSection({
className="rounded-md border border-input bg-background px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium capitalize">{result.node_type}</span>
<span className="text-sm font-medium">
{result.name ?? <span className="capitalize">{result.node_type}</span>}
</span>
<span className="text-xs text-muted-foreground">
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
</span>
</div>
{result.name && (
<p className="text-xs capitalize text-muted-foreground">{result.node_type}</p>
)}
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{result.public_key}
</p>
@@ -1,4 +1,16 @@
import { useState, useEffect } from 'react';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
AreaChart,
Area,
Cell,
} from 'recharts';
import { Separator } from '../ui/separator';
import { api } from '../../api';
import type { StatisticsResponse } from '../../types';
@@ -7,6 +19,94 @@ function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
function NoiseFloorChart({
samples,
}: {
samples: { timestamp: number; noise_floor_dbm: number }[];
}) {
const data = samples.map((s, i) => ({
idx: i,
time: formatTime(s.timestamp),
noise_floor: s.noise_floor_dbm,
}));
const tickCount = Math.min(6, samples.length);
const tickIndices: number[] = [];
if (samples.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (samples.length - 1)));
}
}
return (
<ResponsiveContainer width="100%" height={120}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, samples.length - 1]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => data[idx]?.time ?? ''}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
domain={['dataMin - 5', 'dataMax + 5']}
tickFormatter={(v) => `${v}`}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => data[Number(idx)]?.time ?? ''}
formatter={(value) => [`${value} dBm`, 'Noise Floor']}
/>
<Area
type="linear"
dataKey="noise_floor"
stroke="#8b5cf6"
fill="#8b5cf6"
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, fill: '#8b5cf6', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
export function SettingsStatisticsSection({ className }: { className?: string }) {
const [stats, setStats] = useState<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
@@ -85,60 +185,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span>1-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.single_byte} (
{formatPercent(stats.path_hash_width_24h.single_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>2-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.double_byte} (
{formatPercent(stats.path_hash_width_24h.double_byte_pct)})
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span>3-byte hops</span>
<span className="text-muted-foreground">
{stats.path_hash_width_24h.triple_byte} (
{formatPercent(stats.path_hash_width_24h.triple_byte_pct)})
</span>
</div>
</div>
</div>
<Separator />
{/* Activity */}
<div>
<h4 className="text-sm font-medium mb-2">Activity</h4>
@@ -174,23 +220,172 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</table>
</div>
<Separator />
{/* Packets */}
<div>
<h4 className="text-sm font-medium mb-2">Packets</h4>
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Total stored</span>
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
<Separator />
{/* Path Hash Width */}
<div>
<h4 className="text-sm font-medium mb-2">Path Hash Width (24h)</h4>
<div className="mb-2 text-xs text-muted-foreground">
Parsed stored raw packets from the last 24 hours:{' '}
{stats.path_hash_width_24h.total_packets}
</div>
{stats.path_hash_width_24h.total_packets > 0 ? (
<ResponsiveContainer width="100%" height={120}>
<BarChart
data={[
{
name: '1-byte',
count: stats.path_hash_width_24h.single_byte,
pct: stats.path_hash_width_24h.single_byte_pct,
},
{
name: '2-byte',
count: stats.path_hash_width_24h.double_byte,
pct: stats.path_hash_width_24h.double_byte_pct,
},
{
name: '3-byte',
count: stats.path_hash_width_24h.triple_byte,
pct: stats.path_hash_width_24h.triple_byte_pct,
},
]}
margin={{ top: 4, right: 4, bottom: 0, left: -16 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
vertical={false}
/>
<XAxis
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, _: any, props: any) => [
`${Number(value).toLocaleString()} (${formatPercent(props.payload.pct)})`,
'Packets',
]}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]} maxBarSize={40}>
<Cell fill="#0ea5e9" />
<Cell fill="#10b981" />
<Cell fill="#f59e0b" />
</Bar>
</BarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground">No path data in the last 24 hours.</p>
)}
</div>
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Busiest Channels (24h)</h4>
<div className="space-y-1">
{stats.busiest_channels_24h.map((ch, i) => (
<div key={ch.channel_key} className="flex justify-between items-center text-sm">
<span>
<span className="text-muted-foreground mr-2">{i + 1}.</span>
{ch.channel_name}
</span>
<span className="text-muted-foreground">{ch.message_count} msgs</span>
</div>
))}
</div>
<ResponsiveContainer
width="100%"
height={stats.busiest_channels_24h.length * 28 + 8}
>
<BarChart
data={stats.busiest_channels_24h.map((ch) => ({
name: ch.channel_name,
messages: ch.message_count,
}))}
layout="vertical"
margin={{ top: 0, right: 4, bottom: 0, left: 0 }}
barCategoryGap="20%"
>
<XAxis type="number" hide />
<YAxis
type="category"
dataKey="name"
tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
width={100}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{ fill: 'hsl(var(--muted))', opacity: 0.5 }}
formatter={(value) => [`${Number(value).toLocaleString()} messages`, null]}
/>
<Bar dataKey="messages" radius={[0, 4, 4, 0]} maxBarSize={16}>
{stats.busiest_channels_24h.map((_, i) => (
<Cell key={i} fill={CHANNEL_BAR_COLORS[i % CHANNEL_BAR_COLORS.length]} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</>
)}
{/* Noise Floor */}
{stats.noise_floor_24h.supported !== false && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Noise Floor (24h)</h4>
{stats.noise_floor_24h.latest_noise_floor_dbm != null && (
<div className="mb-2 text-xs text-muted-foreground">
Latest reading: {stats.noise_floor_24h.latest_noise_floor_dbm} dBm
{stats.noise_floor_24h.latest_timestamp != null &&
` at ${new Date(
stats.noise_floor_24h.latest_timestamp * 1000
).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}`}
</div>
)}
{stats.noise_floor_24h.samples.length > 1 ? (
<NoiseFloorChart samples={stats.noise_floor_24h.samples} />
) : stats.noise_floor_24h.samples.length === 0 ? (
<p className="text-sm text-muted-foreground">
No noise floor samples collected yet. Samples are collected every five minutes,
and retained until server restart.
</p>
) : (
<p className="text-sm text-muted-foreground">
Only one sample so far ({stats.noise_floor_24h.samples[0].noise_floor_dbm} dBm).
More data needed for a chart. Samples are collected every five minutes, and
retained until server restart.
</p>
)}
</div>
</>
)}
@@ -275,7 +275,9 @@ interface UseConversationMessagesResult {
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
return (
!!conversation && !['raw', 'map', 'visualizer', 'search', 'trace'].includes(conversation.type)
);
}
function isActiveConversationMessage(
+23 -9
View File
@@ -62,7 +62,6 @@ export function useConversationRouter({
// Only needs channels (fast path) - doesn't wait for contacts
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
@@ -92,6 +91,29 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'trace') {
setActiveConversationState({ type: 'trace', id: 'trace', name: 'Trace' });
hasSetDefaultConversation.current = true;
return;
}
// No hash: optionally restore last-viewed non-data conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' ||
lastViewed.type === 'map' ||
lastViewed.type === 'visualizer' ||
lastViewed.type === 'trace')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
}
if (channels.length === 0) return;
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -109,14 +131,6 @@ export function useConversationRouter({
// No hash: optionally restore last-viewed conversation if enabled on this device.
if (!hashConv && getReopenLastConversationEnabled()) {
const lastViewed = getLastViewedConversation();
if (
lastViewed &&
(lastViewed.type === 'raw' || lastViewed.type === 'map' || lastViewed.type === 'visualizer')
) {
setActiveConversationState(lastViewed);
hasSetDefaultConversation.current = true;
return;
}
if (lastViewed?.type === 'channel') {
const channel =
channels.find((c) => c.key.toLowerCase() === lastViewed.id.toLowerCase()) ||
@@ -43,6 +43,7 @@ interface UseRealtimeAppStateArgs {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
@@ -96,6 +97,7 @@ export function useRealtimeAppState({
observeMessage,
recordMessageEvent,
renameConversationState,
removeConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
@@ -232,6 +234,7 @@ export function useRealtimeAppState({
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
removeConversationMessages(publicKey);
removeConversationState(getStateKey('contact', publicKey));
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -241,6 +244,7 @@ export function useRealtimeAppState({
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
removeConversationMessages(key);
removeConversationState(getStateKey('channel', key));
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -267,6 +271,7 @@ export function useRealtimeAppState({
checkMention,
fetchAllContacts,
fetchConfig,
removeConversationState,
renameConversationState,
renameConversationMessages,
maxRawPackets,
+34 -18
View File
@@ -10,6 +10,14 @@ import {
import type { Channel, Contact, Conversation, Message, UnreadCounts } from '../types';
import { takePrefetchOrFetch } from '../prefetch';
type UnreadTrackedConversation = Conversation & { type: 'channel' | 'contact' };
function isUnreadTrackedConversation(
conversation: Conversation | null
): conversation is UnreadTrackedConversation {
return conversation?.type === 'channel' || conversation?.type === 'contact';
}
interface UseUnreadCountsResult {
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -23,6 +31,7 @@ interface UseUnreadCountsResult {
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
removeConversationState: (stateKey: string) => void;
markAllRead: () => void;
refreshUnreads: () => Promise<void>;
}
@@ -47,14 +56,7 @@ export function useUnreadCounts(
// (the user is already viewing it, so its count should stay at 0).
const applyUnreads = useCallback((data: UnreadCounts) => {
const ac = activeConvRef.current;
const activeKey =
ac &&
ac.type !== 'raw' &&
ac.type !== 'map' &&
ac.type !== 'visualizer' &&
ac.type !== 'search'
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
: null;
const activeKey = isUnreadTrackedConversation(ac) ? getStateKey(ac.type, ac.id) : null;
if (activeKey) {
const counts = { ...data.counts };
@@ -122,16 +124,8 @@ export function useUnreadCounts(
// Mark conversation as read when user views it
// Calls server API to persist read state across devices
useEffect(() => {
if (
activeConversation &&
activeConversation.type !== 'raw' &&
activeConversation.type !== 'map' &&
activeConversation.type !== 'visualizer'
) {
const key = getStateKey(
activeConversation.type as 'channel' | 'contact',
activeConversation.id
);
if (isUnreadTrackedConversation(activeConversation)) {
const key = getStateKey(activeConversation.type, activeConversation.id);
// Update local state immediately for responsive UI
setUnreadCounts((prev) => {
@@ -235,6 +229,27 @@ export function useUnreadCounts(
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
const removeConversationState = useCallback((stateKey: string) => {
setUnreadCounts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setMentions((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
setUnreadLastReadAts((prev) => {
if (!(stateKey in prev)) return prev;
const next = { ...prev };
delete next[stateKey];
return next;
});
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -256,6 +271,7 @@ export function useUnreadCounts(
unreadLastReadAts,
recordMessageEvent,
renameConversationState,
removeConversationState,
markAllRead,
refreshUnreads: fetchUnreads,
};
+1 -1
View File
@@ -85,7 +85,7 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
font-size: 14px;
font-size: 0.875rem;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+2
View File
@@ -5,9 +5,11 @@ import './index.css';
import './themes.css';
import './styles.css';
import { getSavedTheme, applyTheme } from './utils/theme';
import { applyFontScale, getSavedFontScale } from './utils/fontScale';
// Apply saved theme before first render
applyTheme(getSavedTheme());
applyFontScale(getSavedFontScale());
createRoot(document.getElementById('root')!).render(
<StrictMode>
+47
View File
@@ -195,6 +195,53 @@ describe('App startup hash resolution', () => {
});
});
it('restores the trace tool from the URL hash', async () => {
window.location.hash = '#trace';
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores the trace tool from the URL hash even when channels are unavailable', async () => {
window.location.hash = '#trace';
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('reopens the last viewed trace tool even when channels are unavailable', async () => {
window.location.hash = '';
localStorage.setItem(REOPEN_LAST_CONVERSATION_KEY, '1');
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'trace',
id: 'trace',
name: 'Trace',
})
);
mocks.api.getChannels.mockResolvedValue([]);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent('trace:trace:Trace');
}
});
});
it('restores last viewed channel when hash is empty and reopen preference is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
+4 -1
View File
@@ -181,7 +181,10 @@ describe('ContactInfoPane', () => {
await screen.findByText('Mystery');
await waitFor(() => {
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
expect(getContactAnalytics).toHaveBeenCalledWith(
{ name: 'Mystery' },
expect.any(AbortSignal)
);
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
@@ -64,6 +64,10 @@ vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
vi.mock('../components/TracePane', () => ({
TracePane: () => <div data-testid="trace-pane" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
@@ -141,6 +145,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
onPathDiscovery: vi.fn(async () => {
throw new Error('unused');
}),
@@ -231,6 +236,23 @@ describe('ConversationPane', () => {
});
});
it('renders the trace tool pane for trace conversations', () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'trace',
id: 'trace',
name: 'Trace',
},
})}
/>
);
expect(screen.getByTestId('trace-pane')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('gates room chat behind room login controls until authenticated', async () => {
render(
<ConversationPane
+66
View File
@@ -0,0 +1,66 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
applyFontScale,
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
getSavedFontScale,
setSavedFontScale,
} from '../utils/fontScale';
describe('fontScale utilities', () => {
beforeEach(() => {
localStorage.clear();
document.documentElement.style.fontSize = '';
});
afterEach(() => {
document.documentElement.style.fontSize = '';
});
it('defaults to 100% when nothing is saved', () => {
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('reads a saved scale from localStorage', () => {
localStorage.setItem(FONT_SCALE_KEY, '135');
expect(getSavedFontScale()).toBe(135);
});
it('falls back to the default when the saved value is invalid', () => {
localStorage.setItem(FONT_SCALE_KEY, 'giant');
expect(getSavedFontScale()).toBe(DEFAULT_FONT_SCALE);
});
it('applies the scale to the document root', () => {
expect(applyFontScale(150)).toBe(150);
expect(document.documentElement.style.fontSize).toBe('150%');
});
it('stores non-default values and applies them immediately', () => {
expect(setSavedFontScale(137.5)).toBe(137.5);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
it('removes the saved value when returning to the default scale', () => {
localStorage.setItem(FONT_SCALE_KEY, '150');
expect(setSavedFontScale(DEFAULT_FONT_SCALE)).toBe(DEFAULT_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
it('clamps saved and applied values to the supported range', () => {
localStorage.setItem(FONT_SCALE_KEY, '900');
expect(getSavedFontScale()).toBe(MAX_FONT_SCALE);
expect(setSavedFontScale(5)).toBe(MIN_FONT_SCALE);
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe(String(MIN_FONT_SCALE));
expect(document.documentElement.style.fontSize).toBe(`${MIN_FONT_SCALE}%`);
});
});
+75 -8
View File
@@ -20,6 +20,12 @@ import {
} from '../utils/lastViewedConversation';
import { api } from '../api';
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
import {
DEFAULT_FONT_SCALE,
FONT_SCALE_KEY,
MAX_FONT_SCALE,
MIN_FONT_SCALE,
} from '../utils/fontScale';
const baseConfig: RadioConfig = {
public_key: 'aa'.repeat(32),
@@ -187,6 +193,7 @@ describe('SettingsModal', () => {
vi.restoreAllMocks();
localStorage.clear();
window.location.hash = '';
document.documentElement.style.fontSize = '';
});
it('refreshes app settings when opened', async () => {
@@ -301,6 +308,7 @@ describe('SettingsModal', () => {
results: [
{
public_key: '11'.repeat(32),
name: null,
node_type: 'repeater',
heard_count: 2,
local_snr: 7.5,
@@ -549,6 +557,55 @@ describe('SettingsModal', () => {
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
});
it('defaults relative font size to 100% and exposes the expected input bounds', () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
const input = screen.getByLabelText('Relative font size percentage');
expect(slider).toHaveValue(String(DEFAULT_FONT_SCALE));
expect(slider).toHaveAttribute('step', '5');
expect(input).toHaveValue(DEFAULT_FONT_SCALE);
expect(input).toHaveAttribute('min', String(MIN_FONT_SCALE));
expect(input).toHaveAttribute('max', String(MAX_FONT_SCALE));
});
it('stores and applies relative font size changes locally', async () => {
renderModal();
openLocalSection();
const slider = screen.getByLabelText('Relative font size slider');
fireEvent.change(slider, { target: { value: '135' } });
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('');
fireEvent.mouseUp(slider);
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('135');
expect(document.documentElement.style.fontSize).toBe('135%');
});
fireEvent.change(screen.getByLabelText('Relative font size percentage'), {
target: { value: '137.5' },
});
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBe('137.5');
expect(document.documentElement.style.fontSize).toBe('137.5%');
});
fireEvent.click(screen.getByRole('button', { name: 'Reset' }));
await waitFor(() => {
expect(localStorage.getItem(FONT_SCALE_KEY)).toBeNull();
expect(document.documentElement.style.fontSize).toBe('100%');
});
});
it('purges decrypted raw packets via maintenance endpoint action', async () => {
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
packets_deleted: 12,
@@ -595,6 +652,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 3600,
latest_noise_floor_dbm: -105,
latest_timestamp: 1711800000,
supported: true,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
@@ -626,17 +691,11 @@ describe('SettingsModal', () => {
expect(
screen.getByText(/Parsed stored raw packets from the last 24 hours: 120/)
).toBeInTheDocument();
expect(screen.getByText('1-byte hops')).toBeInTheDocument();
expect(screen.getByText('60 (50.0%)')).toBeInTheDocument();
expect(screen.getByText('36 (30.0%)')).toBeInTheDocument();
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
// Busiest channels
expect(screen.getByText('general')).toBeInTheDocument();
expect(screen.getByText('42 msgs')).toBeInTheDocument();
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -663,6 +722,14 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
noise_floor_24h: {
sample_interval_seconds: 300,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
supported: null,
samples: [],
},
};
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+15 -2
View File
@@ -75,13 +75,14 @@ function renderSidebar(overrides?: {
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
const onSelectConversation = vi.fn();
const view = render(
<Sidebar
contacts={[alice, board, relay]}
channels={channels}
activeConversation={null}
onSelectConversation={vi.fn()}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
unreadCounts={unreadCounts}
@@ -96,7 +97,7 @@ function renderSidebar(overrides?: {
/>
);
return { ...view, flightChannel, opsChannel, aliceName, roomName };
return { ...view, flightChannel, opsChannel, aliceName, roomName, onSelectConversation };
}
function getSectionHeaderContainer(title: string): HTMLElement {
@@ -306,6 +307,18 @@ describe('Sidebar section summaries', () => {
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('shows the trace tool row and selects it', () => {
const { onSelectConversation } = renderSidebar();
fireEvent.click(screen.getByText('Trace'));
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'trace',
id: 'trace',
name: 'Trace',
});
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
+262
View File
@@ -0,0 +1,262 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { TracePane } from '../components/TracePane';
import type { Contact, RadioConfig, RadioTraceResponse } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
function makeContact(
publicKey: string,
name: string | null,
type = CONTACT_TYPE_REPEATER,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key: publicKey,
name,
type,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
const config: RadioConfig = {
public_key: 'ff'.repeat(32),
name: 'Base Radio',
lat: 10,
lon: 20,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: true,
};
describe('TracePane', () => {
it('shows only full-key repeaters and filters by name or key', () => {
render(
<TracePane
config={config}
onRunTracePath={vi.fn()}
contacts={[
makeContact('11'.repeat(32), 'Relay Alpha'),
makeContact('22'.repeat(6), 'Prefix Relay'),
makeContact('33'.repeat(32), 'Client Node', 1),
makeContact('44'.repeat(32), 'Relay Beta'),
]}
/>
);
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
expect(screen.queryByText('Prefix Relay')).not.toBeInTheDocument();
expect(screen.queryByText('Client Node')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: 'beta' } });
expect(screen.queryByText('Relay Alpha')).not.toBeInTheDocument();
expect(screen.getByText('Relay Beta')).toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Search repeaters'), { target: { value: '111111' } });
expect(screen.getByText('Relay Alpha')).toBeInTheDocument();
});
it('adds, reorders, removes, and sends a trace path with known repeaters', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayB.public_key,
name: relayB.name,
observed_hash: relayB.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 3.25,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /move relay beta up/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [
{ public_key: relayB.public_key },
{ public_key: relayA.public_key },
]);
});
expect(screen.getByRole('heading', { name: 'Results (6.0s)' })).toBeInTheDocument();
expect(screen.getByText('+7.5 dB')).toBeInTheDocument();
expect(screen.getByText('+5.0 dB')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay alpha/i }));
expect(screen.getByText('1 hop selected · 4-byte trace')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /remove relay beta/i }));
expect(screen.getByText('No hops selected')).toBeInTheDocument();
});
it('allows adding the same repeater multiple times from the picker row', () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
render(<TracePane config={config} onRunTracePath={vi.fn()} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByText('Added 2 times')).toBeInTheDocument();
});
it('adds custom hops from the modal and locks later custom hops to the same byte width', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const onRunTracePath = vi.fn(
async (): Promise<RadioTraceResponse> => ({
path_len: 2,
timeout_seconds: 4.5,
nodes: [
{
role: 'custom',
public_key: null,
name: null,
observed_hash: 'ae',
snr: 4.0,
},
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: '11',
snr: 2.0,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 3.0,
},
],
})
);
render(<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA]} />);
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
fireEvent.click(screen.getByRole('button', { name: '1-byte' }));
fireEvent.change(screen.getByLabelText('Repeater prefix'), { target: { value: 'ae' } });
fireEvent.click(screen.getByRole('button', { name: 'Add custom hop' }));
expect(screen.getByText('1 hop selected · 1-byte trace')).toBeInTheDocument();
expect(screen.getByText('AE (1-byte)')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(1, [
{ hop_hex: 'ae' },
{ public_key: relayA.public_key },
]);
});
fireEvent.click(screen.getByRole('button', { name: 'Custom path' }));
expect(screen.getByRole('button', { name: '2-byte' })).toBeDisabled();
expect(screen.getByRole('button', { name: '4-byte' })).toBeDisabled();
expect(screen.getByText(/custom hops are locked to 1-byte prefixes/i)).toBeInTheDocument();
});
it('drops an in-flight result after the draft path changes', async () => {
const relayA = makeContact('11'.repeat(32), 'Relay Alpha');
const relayB = makeContact('22'.repeat(32), 'Relay Beta');
let resolveTrace: ((value: RadioTraceResponse) => void) | null = null;
const onRunTracePath = vi.fn(
() =>
new Promise<RadioTraceResponse>((resolve) => {
resolveTrace = resolve;
})
);
render(
<TracePane config={config} onRunTracePath={onRunTracePath} contacts={[relayA, relayB]} />
);
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay alpha/i }));
fireEvent.click(screen.getByRole('button', { name: /send trace/i }));
await waitFor(() => {
expect(onRunTracePath).toHaveBeenCalledWith(4, [{ public_key: relayA.public_key }]);
});
fireEvent.click(screen.getByRole('button', { name: /^add repeater relay beta/i }));
expect(screen.getByText('2 hops selected · 4-byte trace')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /send trace/i })).toBeEnabled();
await act(async () => {
resolveTrace?.({
path_len: 1,
timeout_seconds: 6,
nodes: [
{
role: 'repeater',
public_key: relayA.public_key,
name: relayA.name,
observed_hash: relayA.public_key.slice(0, 8),
snr: 7.5,
},
{
role: 'local',
public_key: config.public_key,
name: config.name,
observed_hash: null,
snr: 5.0,
},
],
});
});
expect(screen.queryByRole('heading', { name: 'Results (6.0s)' })).not.toBeInTheDocument();
expect(screen.queryByText('+7.5 dB')).not.toBeInTheDocument();
expect(
screen.getByText('Send a trace to see the returned hop-by-hop SNR values.')
).toBeInTheDocument();
});
});
+8
View File
@@ -52,6 +52,14 @@ describe('parseHashConversation', () => {
expect(result).toEqual({ type: 'map', name: 'map' });
});
it('parses #trace as trace type', () => {
window.location.hash = '#trace';
const result = parseHashConversation();
expect(result).toEqual({ type: 'trace', name: 'trace' });
});
it('parses #map/focus/PUBKEY with focus key', () => {
window.location.hash = '#map/focus/ABCD1234';
@@ -69,6 +69,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
removeConversationState: vi.fn(),
checkMention: vi.fn(() => false),
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
+43
View File
@@ -221,6 +221,49 @@ describe('useUnreadCounts', () => {
});
});
it('does not treat search or trace views as readable conversations', async () => {
const mocks = await getMockedApi();
mocks.getUnreads.mockResolvedValue({
counts: {
[getStateKey('channel', CHANNEL_KEY)]: 4,
[getStateKey('contact', CONTACT_KEY)]: 2,
},
mentions: {
[getStateKey('channel', CHANNEL_KEY)]: true,
},
last_message_times: {},
last_read_ats: {},
});
const { result, rerender } = renderWith({
channels: [makeChannel(CHANNEL_KEY, 'Test')],
contacts: [makeContact(CONTACT_KEY)],
activeConversation: { type: 'search', id: 'search', name: 'Message Search' },
});
await act(async () => {
await vi.waitFor(() => expect(mocks.getUnreads).toHaveBeenCalled());
});
expect(result.current.unreadCounts[getStateKey('channel', CHANNEL_KEY)]).toBe(4);
expect(result.current.unreadCounts[getStateKey('contact', CONTACT_KEY)]).toBe(2);
expect(mocks.markChannelRead).not.toHaveBeenCalled();
expect(mocks.markContactRead).not.toHaveBeenCalled();
rerender({
channels: [makeChannel(CHANNEL_KEY, 'Test')],
contacts: [makeContact(CONTACT_KEY)],
activeConversation: { type: 'trace', id: 'trace', name: 'Trace' },
});
await act(async () => {
await Promise.resolve();
});
expect(mocks.markChannelRead).not.toHaveBeenCalled();
expect(mocks.markContactRead).not.toHaveBeenCalled();
});
it('re-fetches and filters when refreshUnreads is called (simulating WS reconnect)', async () => {
const mocks = await getMockedApi();
const channels = [makeChannel(CHANNEL_KEY, 'Test')];
+36 -1
View File
@@ -34,6 +34,7 @@ export type RadioDiscoveryTarget = 'repeaters' | 'sensors' | 'all';
export interface RadioDiscoveryResult {
public_key: string;
name: string | null;
node_type: 'repeater' | 'sensor';
heard_count: number;
local_snr: number | null;
@@ -285,7 +286,7 @@ export interface ResendChannelMessageResponse {
message?: Message;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
export interface Conversation {
type: ConversationType;
@@ -485,6 +486,25 @@ export interface TraceResponse {
path_len: number;
}
export interface RadioTraceNode {
role: 'repeater' | 'custom' | 'local';
public_key: string | null;
name: string | null;
observed_hash: string | null;
snr: number | null;
}
export interface RadioTraceHopRequest {
public_key?: string | null;
hop_hex?: string | null;
}
export interface RadioTraceResponse {
path_len: number;
timeout_seconds: number;
nodes: RadioTraceNode[];
}
export interface PathDiscoveryRoute {
path: string;
path_len: number;
@@ -516,6 +536,20 @@ interface ContactActivityCounts {
last_week: number;
}
export interface NoiseFloorSample {
timestamp: number;
noise_floor_dbm: number;
}
export interface NoiseFloorHistoryStats {
sample_interval_seconds: number;
coverage_seconds: number;
latest_noise_floor_dbm: number | null;
latest_timestamp: number | null;
supported: boolean | null;
samples: NoiseFloorSample[];
}
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -539,4 +573,5 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
noise_floor_24h: NoiseFloorHistoryStats;
}
+53
View File
@@ -0,0 +1,53 @@
export const FONT_SCALE_KEY = 'remoteterm-font-scale';
export const DEFAULT_FONT_SCALE = 100;
export const MIN_FONT_SCALE = 25;
export const MAX_FONT_SCALE = 400;
export const FONT_SCALE_SLIDER_STEP = 5;
function normalizeFontScale(scale: number): number {
if (!Number.isFinite(scale)) {
return DEFAULT_FONT_SCALE;
}
const clamped = Math.min(MAX_FONT_SCALE, Math.max(MIN_FONT_SCALE, scale));
return Number.parseFloat(clamped.toFixed(2));
}
export function getSavedFontScale(): number {
try {
const raw = localStorage.getItem(FONT_SCALE_KEY);
if (raw === null) {
return DEFAULT_FONT_SCALE;
}
return normalizeFontScale(Number.parseFloat(raw));
} catch {
return DEFAULT_FONT_SCALE;
}
}
export function applyFontScale(scale: number): number {
const normalized = normalizeFontScale(scale);
if (typeof document !== 'undefined') {
document.documentElement.style.fontSize = `${normalized}%`;
}
return normalized;
}
export function setSavedFontScale(scale: number): number {
const normalized = applyFontScale(scale);
try {
if (normalized === DEFAULT_FONT_SCALE) {
localStorage.removeItem(FONT_SCALE_KEY);
} else {
localStorage.setItem(FONT_SCALE_KEY, String(normalized));
}
} catch {
// localStorage may be unavailable
}
return normalized;
}
+12 -1
View File
@@ -4,7 +4,14 @@ import { parseHashConversation } from './urlHash';
export const REOPEN_LAST_CONVERSATION_KEY = 'remoteterm-reopen-last-conversation';
export const LAST_VIEWED_CONVERSATION_KEY = 'remoteterm-last-viewed-conversation';
const SUPPORTED_TYPES: Conversation['type'][] = ['contact', 'channel', 'raw', 'map', 'visualizer'];
const SUPPORTED_TYPES: Conversation['type'][] = [
'contact',
'channel',
'raw',
'map',
'visualizer',
'trace',
];
function isSupportedType(value: unknown): value is Conversation['type'] {
return typeof value === 'string' && SUPPORTED_TYPES.includes(value as Conversation['type']);
@@ -94,6 +101,10 @@ export function captureLastViewedConversationFromHash(): void {
saveLastViewedConversation({ type: 'visualizer', id: 'visualizer', name: 'Mesh Visualizer' });
return;
}
if (hashConversation.type === 'trace') {
saveLastViewedConversation({ type: 'trace', id: 'trace', name: 'Trace' });
return;
}
saveLastViewedConversation({
type: hashConversation.type,
+6 -1
View File
@@ -4,7 +4,7 @@ import { getContactDisplayName } from './pubkey';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
/** Conversation identity token (channel key or contact public key, or legacy name token) */
name: string;
/** Optional human-readable label segment (ignored for identity resolution) */
@@ -44,6 +44,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'search', name: 'search' };
}
if (hash === 'trace') {
return { type: 'trace', name: 'trace' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -149,6 +153,7 @@ function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
if (conv.type === 'search') return '#search';
if (conv.type === 'trace') return '#trace';
// Use immutable IDs for identity, append readable label for UX.
if (conv.type === 'channel') {