mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-01 02:53:00 +02:00
Surface repeater info pane just like contacts
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -233,6 +233,7 @@ export function ConversationPane({
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user