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

View File

@@ -290,6 +290,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| GET | `/api/channels/{key}` | Get channel by key |
| POST | `/api/channels` | Create channel |
| DELETE | `/api/channels/{key}` | Delete channel |

View File

@@ -170,6 +170,7 @@ app/
### Channels
- `GET /channels`
- `GET /channels/{key}/detail`
- `GET /channels/{key}`
- `POST /channels`
- `DELETE /channels/{key}`

View File

@@ -145,6 +145,34 @@ class Channel(BaseModel):
last_read_at: int | None = None # Server-side read state tracking
class ChannelMessageCounts(BaseModel):
"""Time-windowed message counts for a channel."""
last_1h: int = 0
last_24h: int = 0
last_48h: int = 0
last_7d: int = 0
all_time: int = 0
class ChannelTopSender(BaseModel):
"""A top sender in a channel over the last 24 hours."""
sender_name: str
sender_key: str | None = None
message_count: int
class ChannelDetail(BaseModel):
"""Comprehensive channel profile data."""
channel: Channel
message_counts: ChannelMessageCounts = Field(default_factory=ChannelMessageCounts)
first_message_at: int | None = None
unique_sender_count: int = 0
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
class MessagePath(BaseModel):
"""A single path that a message took to reach us."""

View File

@@ -388,6 +388,72 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
"""
import time as _time
now = int(_time.time())
t_1h = now - 3600
t_24h = now - 86400
t_48h = now - 172800
t_7d = now - 604800
cursor = await db.conn.execute(
"""
SELECT COUNT(*) AS all_time,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_1h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_24h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_48h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_7d,
MIN(received_at) AS first_message_at,
COUNT(DISTINCT sender_key) AS unique_sender_count
FROM messages WHERE type = 'CHAN' AND conversation_key = ?
""",
(t_1h, t_24h, t_48h, t_7d, conversation_key),
)
row = await cursor.fetchone()
assert row is not None # Aggregate query always returns a row
message_counts = {
"last_1h": row["last_1h"] or 0,
"last_24h": row["last_24h"] or 0,
"last_48h": row["last_48h"] or 0,
"last_7d": row["last_7d"] or 0,
"all_time": row["all_time"] or 0,
}
cursor2 = await db.conn.execute(
"""
SELECT COALESCE(sender_name, sender_key, 'Unknown') AS display_name,
sender_key, COUNT(*) AS cnt
FROM messages
WHERE type = 'CHAN' AND conversation_key = ?
AND received_at >= ? AND sender_key IS NOT NULL
GROUP BY sender_key ORDER BY cnt DESC LIMIT 5
""",
(conversation_key, t_24h),
)
top_rows = await cursor2.fetchall()
top_senders = [
{
"sender_name": r["display_name"],
"sender_key": r["sender_key"],
"message_count": r["cnt"],
}
for r in top_rows
]
return {
"message_counts": message_counts,
"first_message_at": row["first_message_at"],
"unique_sender_count": row["unique_sender_count"] or 0,
"top_senders_24h": top_senders,
}
@staticmethod
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
"""Get channels where a contact has sent the most messages.

View File

@@ -6,10 +6,10 @@ from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.radio import radio_manager
from app.radio_sync import upsert_channel_from_radio_slot
from app.repository import ChannelRepository
from app.repository import ChannelRepository, MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -29,6 +29,24 @@ async def list_channels() -> list[Channel]:
return await ChannelRepository.get_all()
@router.get("/{key}/detail", response_model=ChannelDetail)
async def get_channel_detail(key: str) -> ChannelDetail:
"""Get comprehensive channel profile data with message statistics."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
stats = await MessageRepository.get_channel_stats(channel.key)
return ChannelDetail(
channel=channel,
message_counts=ChannelMessageCounts(**stats["message_counts"]),
first_message_at=stats["first_message_at"],
unique_sender_count=stats["unique_sender_count"],
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
)
@router.get("/{key}", response_model=Channel)
async def get_channel(key: str) -> Channel:
"""Get a specific channel by key (32-char hex string)."""

View File

@@ -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).

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>
);

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',

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>
);
}

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}

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) */

View File

@@ -1,9 +1,10 @@
"""Tests for the channels router sync endpoint.
"""Tests for the channels router endpoints.
Verifies that POST /api/channels/sync correctly reads channel slots
from the radio and upserts them into the database.
Covers POST /api/channels/sync (radio sync) and GET /api/channels/{key}/detail
(channel stats).
"""
import time
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
@@ -11,7 +12,7 @@ import pytest
from meshcore import EventType
from app.radio import radio_manager
from app.repository import ChannelRepository
from app.repository import ChannelRepository, MessageRepository
@pytest.fixture(autouse=True)
@@ -226,3 +227,127 @@ class TestSyncChannelsFromRadio:
channel = await ChannelRepository.get_by_key("AABBCCDDAABBCCDDAABBCCDDAABBCCDD")
assert channel is not None
class TestChannelDetail:
"""Test GET /api/channels/{key}/detail."""
CHANNEL_KEY = "AABBCCDDAABBCCDDAABBCCDDAABBCCDD"
async def _seed_channel(self):
"""Create a channel in the DB."""
await ChannelRepository.upsert(
key=self.CHANNEL_KEY,
name="#test-channel",
is_hashtag=True,
on_radio=True,
)
async def _insert_message(
self,
conversation_key: str,
text: str,
received_at: int,
sender_key: str | None = None,
sender_name: str | None = None,
) -> int | None:
return await MessageRepository.create(
msg_type="CHAN",
text=text,
received_at=received_at,
conversation_key=conversation_key,
sender_key=sender_key,
sender_name=sender_name,
)
@pytest.mark.asyncio
async def test_detail_basic_stats(self, test_db, client):
"""Channel with messages returns correct counts."""
await self._seed_channel()
now = int(time.time())
# Insert messages at different ages
await self._insert_message(self.CHANNEL_KEY, "recent1", now - 60, "aaa", "Alice")
await self._insert_message(self.CHANNEL_KEY, "recent2", now - 120, "bbb", "Bob")
await self._insert_message(self.CHANNEL_KEY, "old", now - 90000, "aaa", "Alice")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
assert response.status_code == 200
data = response.json()
assert data["channel"]["key"] == self.CHANNEL_KEY
assert data["channel"]["name"] == "#test-channel"
assert data["message_counts"]["all_time"] == 3
assert data["message_counts"]["last_1h"] == 2
assert data["unique_sender_count"] == 2
assert data["first_message_at"] == now - 90000
@pytest.mark.asyncio
async def test_detail_404_unknown_key(self, test_db, client):
"""Unknown channel key returns 404."""
response = await client.get("/api/channels/FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF/detail")
assert response.status_code == 404
@pytest.mark.asyncio
async def test_detail_empty_stats(self, test_db, client):
"""Channel with no messages returns zeroed stats."""
await self._seed_channel()
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
assert response.status_code == 200
data = response.json()
assert data["message_counts"]["all_time"] == 0
assert data["message_counts"]["last_1h"] == 0
assert data["unique_sender_count"] == 0
assert data["first_message_at"] is None
assert data["top_senders_24h"] == []
@pytest.mark.asyncio
async def test_detail_time_window_bucketing(self, test_db, client):
"""Messages at different ages fall into correct time buckets."""
await self._seed_channel()
now = int(time.time())
# 30 min ago → last_1h, last_24h, last_48h, last_7d
await self._insert_message(self.CHANNEL_KEY, "m1", now - 1800, "aaa")
# 2 hours ago → last_24h, last_48h, last_7d (not last_1h)
await self._insert_message(self.CHANNEL_KEY, "m2", now - 7200, "bbb")
# 30 hours ago → last_48h, last_7d (not last_1h or last_24h)
await self._insert_message(self.CHANNEL_KEY, "m3", now - 108000, "ccc")
# 3 days ago → last_7d only
await self._insert_message(self.CHANNEL_KEY, "m4", now - 259200, "ddd")
# 10 days ago → all_time only
await self._insert_message(self.CHANNEL_KEY, "m5", now - 864000, "eee")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
data = response.json()
counts = data["message_counts"]
assert counts["last_1h"] == 1
assert counts["last_24h"] == 2
assert counts["last_48h"] == 3
assert counts["last_7d"] == 4
assert counts["all_time"] == 5
@pytest.mark.asyncio
async def test_detail_top_senders_ordering(self, test_db, client):
"""Top senders are ordered by message count descending."""
await self._seed_channel()
now = int(time.time())
# Alice: 3 messages, Bob: 1 message
for i in range(3):
await self._insert_message(
self.CHANNEL_KEY, f"alice-{i}", now - 60 * (i + 1), "aaa", "Alice"
)
await self._insert_message(self.CHANNEL_KEY, "bob-1", now - 300, "bbb", "Bob")
response = await client.get(f"/api/channels/{self.CHANNEL_KEY}/detail")
data = response.json()
senders = data["top_senders_24h"]
assert len(senders) == 2
assert senders[0]["sender_name"] == "Alice"
assert senders[0]["message_count"] == 3
assert senders[1]["sender_name"] == "Bob"
assert senders[1]["message_count"] == 1