Add channel info box

This commit is contained in:
Jack Kingsman
2026-03-03 17:09:48 -08:00
parent 5d2aaa802b
commit 73a835688d
12 changed files with 536 additions and 18 deletions
+20
View File
@@ -34,6 +34,7 @@ import {
} from './components/settings/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { ChannelInfoPane } from './components/ChannelInfoPane';
import { CONTACT_TYPE_REPEATER } from './types';
// Lazy-load heavy components to reduce initial bundle
@@ -73,6 +74,7 @@ export function App() {
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
@@ -441,6 +443,14 @@ export function App() {
setInfoPaneContactKey(null);
}, []);
const handleOpenChannelInfo = useCallback((channelKey: string) => {
setInfoPaneChannelKey(channelKey);
}, []);
const handleCloseChannelInfo = useCallback(() => {
setInfoPaneChannelKey(null);
}, []);
const handleNavigateToChannel = useCallback(
(channelKey: string) => {
const channel = channels.find((c) => c.key === channelKey);
@@ -613,6 +623,7 @@ export function App() {
<ChatHeader
conversation={activeConversation}
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
onTrace={handleTrace}
@@ -620,6 +631,7 @@ export function App() {
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
onOpenChannelInfo={handleOpenChannelInfo}
/>
<MessageList
key={activeConversation.id}
@@ -760,6 +772,14 @@ export function App() {
onNavigateToChannel={handleNavigateToChannel}
/>
<ChannelInfoPane
channelKey={infoPaneChannelKey}
onClose={handleCloseChannelInfo}
channels={channels}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
/>
<Toaster position="top-right" />
</div>
);
+2
View File
@@ -2,6 +2,7 @@ import type {
AppSettings,
AppSettingsUpdate,
Channel,
ChannelDetail,
CommandResponse,
Contact,
ContactAdvertPath,
@@ -148,6 +149,7 @@ export const api = {
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
markChannelRead: (key: string) =>
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
method: 'POST',
+214
View File
@@ -0,0 +1,214 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite } from '../types';
interface ChannelInfoPaneProps {
channelKey: string | null;
onClose: () => void;
channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
}
export function ChannelInfoPane({
channelKey,
onClose,
channels,
favorites,
onToggleFavorite,
}: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [loading, setLoading] = useState(false);
// Get live channel data from channels array (real-time via WS)
const liveChannel = channelKey ? (channels.find((c) => c.key === channelKey) ?? null) : null;
useEffect(() => {
if (!channelKey) {
setDetail(null);
return;
}
let cancelled = false;
setLoading(true);
api
.getChannelDetail(channelKey)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch channel detail:', err);
toast.error('Failed to load channel info');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [channelKey]);
// Use live channel data where available, fall back to detail snapshot
const channel = liveChannel ?? detail?.channel ?? null;
return (
<Sheet open={channelKey !== 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>Channel Info</SheetTitle>
</SheetHeader>
{loading && !detail ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
) : channel ? (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<h2 className="text-lg font-semibold truncate">
{channel.name.startsWith('#') || channel.name === 'Public'
? channel.name
: `#${channel.name}`}
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(channel.key);
toast.success('Channel key copied!');
}}
title="Click to copy"
>
{channel.key.toLowerCase()}
</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">
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
</span>
{channel.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>
{/* 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('channel', channel.key)}
>
{isFavorite(favorites, 'channel', channel.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>
{/* Message Activity */}
{detail && detail.message_counts.all_time > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Message Activity</SectionLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<InfoItem
label="Last Hour"
value={detail.message_counts.last_1h.toLocaleString()}
/>
<InfoItem
label="Last 24h"
value={detail.message_counts.last_24h.toLocaleString()}
/>
<InfoItem
label="Last 48h"
value={detail.message_counts.last_48h.toLocaleString()}
/>
<InfoItem
label="Last 7d"
value={detail.message_counts.last_7d.toLocaleString()}
/>
<InfoItem
label="All Time"
value={detail.message_counts.all_time.toLocaleString()}
/>
<InfoItem
label="Unique Senders"
value={detail.unique_sender_count.toLocaleString()}
/>
</div>
</div>
)}
{/* First Message */}
{detail && detail.first_message_at && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>First Message</SectionLabel>
<p className="text-sm font-medium">{formatTime(detail.first_message_at)}</p>
</div>
)}
{/* Top Senders 24h */}
{detail && detail.top_senders_24h.length > 0 && (
<div className="px-5 py-3">
<SectionLabel>Top Senders (24h)</SectionLabel>
<div className="space-y-1">
{detail.top_senders_24h.map((sender, idx) => (
<div
key={sender.sender_key ?? idx}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{sender.sender_name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{sender.message_count.toLocaleString()} msg
{sender.message_count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Channel 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>
);
}
+21 -12
View File
@@ -3,11 +3,12 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
interface ChatHeaderProps {
conversation: Conversation;
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
onTrace: () => void;
@@ -15,11 +16,13 @@ interface ChatHeaderProps {
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
onOpenChannelInfo?: (channelKey: string) => void;
}
export function ChatHeader({
conversation,
contacts,
channels,
config,
favorites,
onTrace,
@@ -27,7 +30,11 @@ export function ChatHeader({
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
onOpenChannelInfo,
}: ChatHeaderProps) {
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
return (
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
@@ -50,23 +57,25 @@ export function ChatHeader({
</span>
)}
<h2
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={conversation.type === 'contact' && onOpenContactInfo ? 'button' : undefined}
tabIndex={conversation.type === 'contact' && onOpenContactInfo ? 0 : undefined}
onKeyDown={
conversation.type === 'contact' && onOpenContactInfo
? handleKeyboardActivate
: undefined
}
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
conversation.type === 'contact' && onOpenContactInfo
? () => onOpenContactInfo(conversation.id)
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
conversation.name !== 'Public'
channels.find((c) => c.key === conversation.id)?.is_hashtag
? '#'
: ''}
{conversation.name}
+22
View File
@@ -109,6 +109,28 @@ export interface Channel {
last_read_at: number | null;
}
export interface ChannelMessageCounts {
last_1h: number;
last_24h: number;
last_48h: number;
last_7d: number;
all_time: number;
}
export interface ChannelTopSender {
sender_name: string;
sender_key: string | null;
message_count: number;
}
export interface ChannelDetail {
channel: Channel;
message_counts: ChannelMessageCounts;
first_message_at: number | null;
unique_sender_count: number;
top_senders_24h: ChannelTopSender[];
}
/** A single path that a message took to reach us */
export interface MessagePath {
/** Hex-encoded routing path (2 chars per hop) */