mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 16:53:38 +02:00
Add channel info box
This commit is contained in:
@@ -259,6 +259,18 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
## Channel Info Pane
|
||||
|
||||
Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right drawer) showing channel details fetched from `GET /api/channels/{key}/detail`:
|
||||
|
||||
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
|
||||
- Favorite toggle
|
||||
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
|
||||
- First message date
|
||||
- Top senders in last 24h (name + count)
|
||||
|
||||
State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
|
||||
## Repeater Dashboard
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -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">★</span>
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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) */
|
||||
|
||||
Reference in New Issue
Block a user