Surface repeater info pane just like contacts

This commit is contained in:
Jack Kingsman
2026-04-01 14:08:30 -07:00
parent 4ff6d2018a
commit e437ce74c6
4 changed files with 138 additions and 40 deletions

View File

@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useState } from 'react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import {
LineChart,
@@ -35,6 +35,7 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type {
Contact,
ContactActiveRoom,
@@ -158,6 +159,7 @@ export function ContactInfoPane({
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -396,8 +398,8 @@ export function ContactInfoPane({
</button>
</div>
{/* Block toggles */}
{(onToggleBlockedKey || onToggleBlockedName) && (
{/* Block toggles (not applicable to repeaters) */}
{!isRepeater && (onToggleBlockedKey || onToggleBlockedName) && (
<div className="px-5 py-3 border-b border-border space-y-2">
{onToggleBlockedKey && (
<button
@@ -440,7 +442,7 @@ export function ContactInfoPane({
</div>
)}
{onSearchMessagesByKey && (
{!isRepeater && onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
@@ -453,40 +455,60 @@ export function ContactInfoPane({
</div>
)}
{/* Nearest Repeaters */}
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{analytics.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>
{/* Nearest Repeaters (Hops) — last 7 days only */}
{analytics &&
(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const recent = analytics.nearest_repeaters.filter(
(r) => r.last_seen >= sevenDaysAgo
);
if (recent.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel>
<div className="space-y-1">
{recent.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>
</div>
</div>
);
})()}
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
)}
{/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1">
<div className="space-y-1.5">
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
className="flex justify-between items-start gap-2 text-sm"
>
<span className="font-mono text-xs truncate">
<span className="font-mono text-xs break-all">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
<span className="text-xs text-muted-foreground flex-shrink-0">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
@@ -518,17 +540,21 @@ export function ContactInfoPane({
</div>
)}
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
{!isRepeater && (
<>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -826,6 +852,60 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
);
}
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>

View File

@@ -233,6 +233,7 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/>
</Suspense>
);

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -45,6 +45,7 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function RepeaterDashboard({
@@ -62,6 +63,7 @@ export function RepeaterDashboard({
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -115,9 +117,24 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
{conversation.name}
</span>
<h2 className="min-w-0 flex-shrink font-semibold text-base">
{onOpenContactInfo ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{conversation.name}</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">{conversation.name}</span>
)}
</h2>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"

View File

@@ -749,8 +749,8 @@ export function SettingsRadioSection({
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts} contacts.
The effective cap will be limited to what the radio supports.
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div>