From 73a835688dbd690b2a82f358afd52b883c7bd845 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Tue, 3 Mar 2026 17:09:48 -0800 Subject: [PATCH] Add channel info box --- AGENTS.md | 1 + app/AGENTS.md | 1 + app/models.py | 28 +++ app/repository/messages.py | 66 ++++++ app/routers/channels.py | 22 +- frontend/AGENTS.md | 12 ++ frontend/src/App.tsx | 20 ++ frontend/src/api.ts | 2 + frontend/src/components/ChannelInfoPane.tsx | 214 ++++++++++++++++++++ frontend/src/components/ChatHeader.tsx | 33 +-- frontend/src/types.ts | 22 ++ tests/test_channels_router.py | 133 +++++++++++- 12 files changed, 536 insertions(+), 18 deletions(-) create mode 100644 frontend/src/components/ChannelInfoPane.tsx diff --git a/AGENTS.md b/AGENTS.md index 2d36b8a..d2a1133 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 | diff --git a/app/AGENTS.md b/app/AGENTS.md index 2e707eb..c512a76 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -170,6 +170,7 @@ app/ ### Channels - `GET /channels` +- `GET /channels/{key}/detail` - `GET /channels/{key}` - `POST /channels` - `DELETE /channels/{key}` diff --git a/app/models.py b/app/models.py index 9c4a366..8a08c4b 100644 --- a/app/models.py +++ b/app/models.py @@ -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.""" diff --git a/app/repository/messages.py b/app/repository/messages.py index 279bd17..5198547 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -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. diff --git a/app/routers/channels.py b/app/routers/channels.py index 1535d5e..47d8d25 100644 --- a/app/routers/channels.py +++ b/app/routers/channels.py @@ -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).""" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6a0563c..255f41a 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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). diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73ab1ab..68b9fed 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); + const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(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() { + + ); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c8c208a..8b2e8c0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(`/channels/${key}/detail`), markChannelRead: (key: string) => fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, { method: 'POST', diff --git a/frontend/src/components/ChannelInfoPane.tsx b/frontend/src/components/ChannelInfoPane.tsx new file mode 100644 index 0000000..f2aacb5 --- /dev/null +++ b/frontend/src/components/ChannelInfoPane.tsx @@ -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(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 ( + !open && onClose()}> + + + Channel Info + + + {loading && !detail ? ( +
+ Loading... +
+ ) : channel ? ( +
+ {/* Header */} +
+

+ {channel.name.startsWith('#') || channel.name === 'Public' + ? channel.name + : `#${channel.name}`} +

+ { + navigator.clipboard.writeText(channel.key); + toast.success('Channel key copied!'); + }} + title="Click to copy" + > + {channel.key.toLowerCase()} + +
+ + {channel.is_hashtag ? 'Hashtag' : 'Private Key'} + + {channel.on_radio && ( + + On Radio + + )} +
+
+ + {/* Favorite toggle */} +
+ +
+ + {/* Message Activity */} + {detail && detail.message_counts.all_time > 0 && ( +
+ Message Activity +
+ + + + + + +
+
+ )} + + {/* First Message */} + {detail && detail.first_message_at && ( +
+ First Message +

{formatTime(detail.first_message_at)}

+
+ )} + + {/* Top Senders 24h */} + {detail && detail.top_senders_24h.length > 0 && ( +
+ Top Senders (24h) +
+ {detail.top_senders_24h.map((sender, idx) => ( +
+ {sender.sender_name} + + {sender.message_count.toLocaleString()} msg + {sender.message_count !== 1 ? 's' : ''} + +
+ ))} +
+
+ )} +
+ ) : ( +
+ Channel not found +
+ )} +
+
+ ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+ {label} +

{value}

+
+ ); +} diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 9f6c129..cf81dec 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -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 (
@@ -50,23 +57,25 @@ export function ChatHeader({ )}

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} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4141bd0..7f6b654 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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) */ diff --git a/tests/test_channels_router.py b/tests/test_channels_router.py index 3cadb77..963d30e 100644 --- a/tests/test_channels_router.py +++ b/tests/test_channels_router.py @@ -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