mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Contact info pane
This commit is contained in:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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">★</span>
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</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)} – {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>
|
||||
);
|
||||
}
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -282,6 +282,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
on_radio: true,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user