Contact info pane

This commit is contained in:
Jack Kingsman
2026-02-27 13:38:53 -08:00
parent 24166e92e8
commit b91b2d5d7b
28 changed files with 1624 additions and 162 deletions
+33
View File
@@ -34,6 +34,7 @@ import {
type SettingsSection,
} from './components/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
// Lazy-load heavy components to reduce initial bundle
const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
@@ -68,6 +69,7 @@ export function App() {
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
@@ -410,6 +412,25 @@ export function App() {
setShowCracker((prev) => !prev);
}, []);
const handleOpenContactInfo = useCallback((publicKey: string) => {
setInfoPaneContactKey(publicKey);
}, []);
const handleCloseContactInfo = useCallback(() => {
setInfoPaneContactKey(null);
}, []);
const handleNavigateToChannel = useCallback(
(channelKey: string) => {
const channel = channels.find((c) => c.key === channelKey);
if (channel) {
handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name });
setInfoPaneContactKey(null);
}
},
[channels, handleSelectConversation]
);
// Sidebar content (shared between desktop and mobile)
const sidebarContent = (
<Sidebar
@@ -552,6 +573,7 @@ export function App() {
onToggleFavorite={handleToggleFavorite}
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
/>
<MessageList
key={activeConversation.id}
@@ -569,6 +591,7 @@ export function App() {
}
radioName={config?.name}
config={config}
onOpenContactInfo={handleOpenContactInfo}
/>
<MessageInput
ref={messageInputRef}
@@ -692,6 +715,16 @@ export function App() {
onCreateHashtagChannel={handleCreateHashtagChannel}
/>
<ContactInfoPane
contactKey={infoPaneContactKey}
onClose={handleCloseContactInfo}
contacts={contacts}
config={config}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onNavigateToChannel={handleNavigateToChannel}
/>
<Toaster position="top-right" />
</div>
);
+7 -4
View File
@@ -4,6 +4,9 @@ import type {
Channel,
CommandResponse,
Contact,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
Favorite,
HealthStatus,
MaintenanceResult,
@@ -12,8 +15,6 @@ import type {
MigratePreferencesResponse,
RadioConfig,
RadioConfigUpdate,
RepeaterAdvertPath,
RepeaterAdvertPathSummary,
StatisticsResponse,
TelemetryResponse,
TraceResponse,
@@ -97,11 +98,13 @@ export const api = {
getContacts: (limit = 100, offset = 0) =>
fetchJson<Contact[]>(`/contacts?limit=${limit}&offset=${offset}`),
getRepeaterAdvertPaths: (limitPerRepeater = 10) =>
fetchJson<RepeaterAdvertPathSummary[]>(
fetchJson<ContactAdvertPathSummary[]>(
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<RepeaterAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
getContactDetail: (publicKey: string) =>
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
+27 -2
View File
@@ -4,6 +4,7 @@ import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { ContactAvatar } from './ContactAvatar';
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
interface ChatHeaderProps {
@@ -15,6 +16,7 @@ interface ChatHeaderProps {
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function ChatHeader({
@@ -26,11 +28,34 @@ export function ChatHeader({
onToggleFavorite,
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
}: ChatHeaderProps) {
return (
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
>
<ContactAvatar
name={conversation.name}
publicKey={conversation.id}
size={28}
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
clickable
/>
</span>
)}
<span
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
onClick={
conversation.type === 'contact' && onOpenContactInfo
? () => onOpenContactInfo(conversation.id)
: undefined
}
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
conversation.name !== 'Public'
+9 -2
View File
@@ -5,14 +5,21 @@ interface ContactAvatarProps {
publicKey: string;
size?: number;
contactType?: number;
clickable?: boolean;
}
export function ContactAvatar({ name, publicKey, size = 28, contactType }: ContactAvatarProps) {
export function ContactAvatar({
name,
publicKey,
size = 28,
contactType,
clickable,
}: ContactAvatarProps) {
const avatar = getContactAvatar(name, publicKey, contactType);
return (
<div
className="flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none"
className={`flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none${clickable ? ' cursor-pointer' : ''}`}
style={{
backgroundColor: avatar.background,
color: avatar.textColor,
+338
View File
@@ -0,0 +1,338 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
interface ContactInfoPaneProps {
contactKey: string | null;
onClose: () => void;
contacts: Contact[];
config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
}
export function ContactInfoPane({
contactKey,
onClose,
contacts,
config,
favorites,
onToggleFavorite,
onNavigateToChannel,
}: ContactInfoPaneProps) {
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [loading, setLoading] = useState(false);
// Get live contact data from contacts array (real-time via WS)
const liveContact = contactKey
? (contacts.find((c) => c.public_key === contactKey) ?? null)
: null;
useEffect(() => {
if (!contactKey) {
setDetail(null);
return;
}
setLoading(true);
api
.getContactDetail(contactKey)
.then(setDetail)
.catch((err) => {
console.error('Failed to fetch contact detail:', err);
toast.error('Failed to load contact info');
})
.finally(() => setLoading(false));
}, [contactKey]);
// Use live contact data where available, fall back to detail snapshot
const contact = liveContact ?? detail?.contact ?? null;
const distFromUs =
contact &&
config &&
isValidLocation(config.lat, config.lon) &&
isValidLocation(contact.lat, contact.lon)
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Contact Info</SheetTitle>
</SheetHeader>
{loading && !detail ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
) : contact ? (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar
name={contact.name}
publicKey={contact.public_key}
size={56}
contactType={contact.type}
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">
{contact.name || contact.public_key.slice(0, 12)}
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
onClick={() => {
navigator.clipboard.writeText(contact.public_key);
toast.success('Public key copied!');
}}
title="Click to copy"
>
{contact.public_key}
</span>
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
</span>
{contact.on_radio && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio
</span>
)}
</div>
</div>
</div>
</div>
{/* Info grid */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
{contact.last_seen && (
<InfoItem label="Last Seen" value={formatTime(contact.last_seen)} />
)}
{contact.first_seen && (
<InfoItem label="First Heard" value={formatTime(contact.first_seen)} />
)}
{contact.last_contacted && (
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
)}
{distFromUs !== null && (
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
)}
{contact.last_path_len >= 0 && (
<InfoItem
label="Hops"
value={
contact.last_path_len === 0
? 'Direct'
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
}
/>
)}
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
</div>
</div>
{/* GPS */}
{isValidLocation(contact.lat, contact.lon) && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Location</SectionLabel>
<span
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
onClick={() => {
const url =
window.location.origin +
window.location.pathname +
getMapFocusHash(contact.public_key);
window.open(url, '_blank');
}}
title="View on map"
>
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
</span>
</div>
)}
{/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border">
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('contact', contact.public_key)}
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
<>
<span className="text-amber-400 text-lg">&#9733;</span>
<span>Remove from favorites</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#9734;</span>
<span>Add to favorites</span>
</>
)}
</button>
</div>
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Also Known As</SectionLabel>
<div className="space-y-1">
{detail.name_history.map((h) => (
<div key={h.name} className="flex justify-between items-center text-sm">
<span className="font-medium truncate">{h.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatTime(h.first_seen)} &ndash; {formatTime(h.last_seen)}
</span>
</div>
))}
</div>
</div>
)}
{/* Message Stats */}
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Messages</SectionLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{detail.dm_message_count > 0 && (
<InfoItem
label="Direct Messages"
value={detail.dm_message_count.toLocaleString()}
/>
)}
{detail.channel_message_count > 0 && (
<InfoItem
label="Channel Messages"
value={detail.channel_message_count.toLocaleString()}
/>
)}
</div>
</div>
)}
{/* Most Active Rooms */}
{detail && detail.most_active_rooms.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Most Active Rooms</SectionLabel>
<div className="space-y-1">
{detail.most_active_rooms.map((room) => (
<div
key={room.channel_key}
className="flex justify-between items-center text-sm"
>
<span
className={
onNavigateToChannel
? 'cursor-pointer hover:text-primary transition-colors truncate'
: 'truncate'
}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
? room.channel_name
: `#${room.channel_name}`}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
{/* Advert Info */}
{detail && detail.advert_frequency !== null && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Advert Observations</SectionLabel>
<p className="text-sm">{detail.advert_frequency.toFixed(1)} observations/hour</p>
</div>
)}
{/* Nearest Repeaters */}
{detail && detail.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{detail.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
</div>
</div>
)}
{/* Advert Paths */}
{detail && detail.advert_paths.length > 0 && (
<div className="px-5 py-3">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1">
{detail.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs truncate">{p.path || '(direct)'}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Contact not found
</div>
)}
</SheetContent>
</Sheet>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children}
</h3>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground text-xs">{label}</span>
<p className="font-medium text-sm leading-tight">{value}</p>
</div>
);
}
+16 -1
View File
@@ -26,6 +26,7 @@ interface MessageListProps {
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string) => void;
}
// URL regex for linkifying plain text
@@ -148,6 +149,7 @@ export function MessageList({
onResendChannelMessage,
radioName,
config,
onOpenContactInfo,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
@@ -466,7 +468,20 @@ export function MessageList({
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<ContactAvatar name={avatarName} publicKey={avatarKey} size={32} />
<span
onClick={
onOpenContactInfo && !avatarKey.startsWith('name:')
? () => onOpenContactInfo(avatarKey)
: undefined
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
/>
</span>
)}
</div>
)}
@@ -22,7 +22,7 @@ import {
type Contact,
type RawPacket,
type RadioConfig,
type RepeaterAdvertPathSummary,
type ContactAdvertPathSummary,
} from '../types';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
import { Checkbox } from './ui/checkbox';
@@ -118,7 +118,7 @@ interface UseVisualizerData3DOptions {
packets: RawPacket[];
contacts: Contact[];
config: RadioConfig | null;
repeaterAdvertPaths: RepeaterAdvertPathSummary[];
repeaterAdvertPaths: ContactAdvertPathSummary[];
showAmbiguousPaths: boolean;
showAmbiguousNodes: boolean;
useAdvertPathHints: boolean;
@@ -195,9 +195,9 @@ function useVisualizerData3D({
}, [contacts]);
const advertPathIndex = useMemo(() => {
const byRepeater = new Map<string, RepeaterAdvertPathSummary['paths']>();
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
for (const summary of repeaterAdvertPaths) {
const key = summary.repeater_key.slice(0, 12).toLowerCase();
const key = summary.public_key.slice(0, 12).toLowerCase();
byRepeater.set(key, summary.paths);
}
return { byRepeater };
@@ -1018,7 +1018,7 @@ export function PacketVisualizer3D({
const [showControls, setShowControls] = useState(true);
const [autoOrbit, setAutoOrbit] = useState(false);
const [pruneStaleNodes, setPruneStaleNodes] = useState(false);
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<RepeaterAdvertPathSummary[]>([]);
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<ContactAdvertPathSummary[]>([]);
useEffect(() => {
let cancelled = false;
@@ -272,6 +272,7 @@ describe('App startup hash resolution', () => {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
window.location.hash = '';
+1
View File
@@ -282,6 +282,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
on_radio: true,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
+1
View File
@@ -26,6 +26,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
+1
View File
@@ -30,6 +30,7 @@ function makeContact(public_key: string, name: string, type = 1): Contact {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
}
+3
View File
@@ -203,6 +203,7 @@ describe('resolveContactFromHashToken', () => {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
{
public_key: 'def456abc1237890def456abc1237890def456abc1237890def456abc1237890',
@@ -218,6 +219,7 @@ describe('resolveContactFromHashToken', () => {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
{
public_key: 'eeeeee111111222222333333444444555555666666777777888888999999aaaa',
@@ -233,6 +235,7 @@ describe('resolveContactFromHashToken', () => {
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
+36 -4
View File
@@ -50,9 +50,10 @@ export interface Contact {
on_radio: boolean;
last_contacted: number | null;
last_read_at: number | null;
first_seen: number | null;
}
export interface RepeaterAdvertPath {
export interface ContactAdvertPath {
path: string;
path_len: number;
next_hop: string | null;
@@ -61,9 +62,40 @@ export interface RepeaterAdvertPath {
heard_count: number;
}
export interface RepeaterAdvertPathSummary {
repeater_key: string;
paths: RepeaterAdvertPath[];
export interface ContactAdvertPathSummary {
public_key: string;
paths: ContactAdvertPath[];
}
export interface ContactNameHistory {
name: string;
first_seen: number;
last_seen: number;
}
export interface ContactActiveRoom {
channel_key: string;
channel_name: string;
message_count: number;
}
export interface NearestRepeater {
public_key: string;
name: string | null;
path_len: number;
last_seen: number;
heard_count: number;
}
export interface ContactDetail {
contact: Contact;
name_history: ContactNameHistory[];
dm_message_count: number;
channel_message_count: number;
most_active_rooms: ContactActiveRoom[];
advert_paths: ContactAdvertPath[];
advert_frequency: number | null;
nearest_repeaters: NearestRepeater[];
}
export interface Channel {