diff --git a/app/models.py b/app/models.py index f1145ef..56bf5e8 100644 --- a/app/models.py +++ b/app/models.py @@ -225,6 +225,14 @@ class ContactDetail(BaseModel): nearest_repeaters: list[NearestRepeater] = Field(default_factory=list) +class NameOnlyContactDetail(BaseModel): + """Channel activity summary for a sender name that is not tied to a known key.""" + + name: str + channel_message_count: int = 0 + most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list) + + class Channel(BaseModel): key: str = Field(description="Channel key (32-char hex)") name: str diff --git a/app/repository/messages.py b/app/repository/messages.py index d0c88d4..5ef251e 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -545,6 +545,16 @@ class MessageRepository: row = await cursor.fetchone() return row["cnt"] if row else 0 + @staticmethod + async def count_channel_messages_by_sender_name(sender_name: str) -> int: + """Count channel messages attributed to a display name.""" + cursor = await db.conn.execute( + "SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_name = ?", + (sender_name,), + ) + 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. @@ -632,3 +642,24 @@ class MessageRepository: ) rows = await cursor.fetchall() return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows] + + @staticmethod + async def get_most_active_rooms_by_sender_name( + sender_name: str, limit: int = 5 + ) -> list[tuple[str, str, int]]: + """Get channels where a display name has sent the most messages.""" + cursor = await db.conn.execute( + """ + SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name, + COUNT(*) AS cnt + FROM messages m + LEFT JOIN channels c ON m.conversation_key = c.key + WHERE m.type = 'CHAN' AND m.sender_name = ? + GROUP BY m.conversation_key + ORDER BY cnt DESC + LIMIT ? + """, + (sender_name, limit), + ) + rows = await cursor.fetchall() + return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows] diff --git a/app/routers/contacts.py b/app/routers/contacts.py index 3168736..65ed1bf 100644 --- a/app/routers/contacts.py +++ b/app/routers/contacts.py @@ -14,6 +14,7 @@ from app.models import ( ContactRoutingOverrideRequest, ContactUpsert, CreateContactRequest, + NameOnlyContactDetail, NearestRepeater, TraceResponse, ) @@ -249,6 +250,29 @@ async def get_contact_detail(public_key: str) -> ContactDetail: ) +@router.get("/name-detail", response_model=NameOnlyContactDetail) +async def get_name_only_contact_detail( + name: str = Query(min_length=1, max_length=200), +) -> NameOnlyContactDetail: + """Get channel activity summary for a sender name without a resolved key.""" + normalized_name = name.strip() + if not normalized_name: + raise HTTPException(status_code=400, detail="name is required") + + chan_count = await MessageRepository.count_channel_messages_by_sender_name(normalized_name) + active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(normalized_name) + most_active_rooms = [ + ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count) + for key, room_name, count in active_rooms_raw + ] + + return NameOnlyContactDetail( + name=normalized_name, + channel_message_count=chan_count, + most_active_rooms=most_active_rooms, + ) + + @router.get("/{public_key}", response_model=Contact) async def get_contact(public_key: str) -> Contact: """Get a specific contact by public key or prefix.""" diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3b32be1..c7641f5 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -16,6 +16,7 @@ import type { MessagesAroundResponse, MigratePreferencesRequest, MigratePreferencesResponse, + NameOnlyContactDetail, RadioConfig, RadioConfigUpdate, RepeaterAclResponse, @@ -115,6 +116,8 @@ export const api = { fetchJson(`/contacts/${publicKey}/advert-paths?limit=${limit}`), getContactDetail: (publicKey: string) => fetchJson(`/contacts/${publicKey}/detail`), + getNameOnlyContactDetail: (name: string) => + fetchJson(`/contacts/name-detail?name=${encodeURIComponent(name)}`), deleteContact: (publicKey: string) => fetchJson<{ status: string }>(`/contacts/${publicKey}`, { method: 'DELETE', diff --git a/frontend/src/components/ContactInfoPane.tsx b/frontend/src/components/ContactInfoPane.tsx index 3ec486a..5d965ce 100644 --- a/frontend/src/components/ContactInfoPane.tsx +++ b/frontend/src/components/ContactInfoPane.tsx @@ -17,7 +17,14 @@ import { handleKeyboardActivate } from '../utils/a11y'; import { ContactAvatar } from './ContactAvatar'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { toast } from './ui/sonner'; -import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types'; +import type { + Contact, + ContactActiveRoom, + ContactDetail, + Favorite, + NameOnlyContactDetail, + RadioConfig, +} from '../types'; const CONTACT_TYPE_LABELS: Record = { 0: 'Unknown', @@ -67,6 +74,7 @@ export function ContactInfoPane({ const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null; const [detail, setDetail] = useState(null); + const [nameOnlyDetail, setNameOnlyDetail] = useState(null); const [loading, setLoading] = useState(false); // Get live contact data from contacts array (real-time via WS) @@ -100,6 +108,33 @@ export function ContactInfoPane({ }; }, [contactKey, isNameOnly]); + useEffect(() => { + if (!nameOnlyValue) { + setNameOnlyDetail(null); + return; + } + + let cancelled = false; + setLoading(true); + api + .getNameOnlyContactDetail(nameOnlyValue) + .then((data) => { + if (!cancelled) setNameOnlyDetail(data); + }) + .catch((err) => { + if (!cancelled) { + console.error('Failed to fetch name-only contact detail:', err); + toast.error('Failed to load contact info'); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [nameOnlyValue]); + // Use live contact data where available, fall back to detail snapshot const contact = liveContact ?? detail?.contact ?? null; @@ -141,8 +176,6 @@ export function ContactInfoPane({ - {fromChannel && } - {/* Block by name toggle */} {onToggleBlockedName && (
@@ -165,6 +198,25 @@ export function ContactInfoPane({
)} + + {fromChannel && ( + + )} + + + + ) : loading && !detail ? (
@@ -212,8 +264,6 @@ export function ContactInfoPane({
- {fromChannel && } - {/* Info grid */}
@@ -340,79 +390,6 @@ export function ContactInfoPane({
)} - {/* AKA (Name History) - only show if more than one name */} - {detail && detail.name_history.length > 1 && ( -
- Also Known As -
- {detail.name_history.map((h) => ( -
- {h.name} - - {formatTime(h.first_seen)} – {formatTime(h.last_seen)} - -
- ))} -
-
- )} - - {/* Message Stats */} - {detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && ( -
- Messages -
- {detail.dm_message_count > 0 && ( - - )} - {detail.channel_message_count > 0 && ( - - )} -
-
- )} - - {/* Most Active Rooms */} - {detail && detail.most_active_rooms.length > 0 && ( -
- Most Active Rooms -
- {detail.most_active_rooms.map((room) => ( -
- onNavigateToChannel?.(room.channel_key)} - > - {room.channel_name.startsWith('#') || room.channel_name === 'Public' - ? room.channel_name - : `#${room.channel_name}`} - - - {room.message_count.toLocaleString()} msg - {room.message_count !== 1 ? 's' : ''} - -
- ))} -
-
- )} - {/* Nearest Repeaters */} {detail && detail.nearest_repeaters.length > 0 && (
@@ -435,7 +412,7 @@ export function ContactInfoPane({ {/* Advert Paths */} {detail && detail.advert_paths.length > 0 && ( -
+
Recent Advert Paths
{detail.advert_paths.map((p) => ( @@ -454,6 +431,39 @@ export function ContactInfoPane({
)} + + {fromChannel && ( + 1)} + /> + )} + + {/* AKA (Name History) - only show if more than one name */} + {detail && detail.name_history.length > 1 && ( +
+ Also Known As +
+ {detail.name_history.map((h) => ( +
+ {h.name} + + {formatTime(h.first_seen)} – {formatTime(h.last_seen)} + +
+ ))} +
+
+ )} + + + +
) : (
@@ -473,18 +483,99 @@ function SectionLabel({ children }: { children: React.ReactNode }) { ); } -function ChannelAttributionWarning() { +function ChannelAttributionWarning({ + includeAliasNote = false, + nameOnly = false, + className = 'mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20', +}: { + includeAliasNote?: boolean; + nameOnly?: boolean; + className?: string; +}) { return ( -
+

Channel sender identity is based on best-effort name matching. Different nodes using the - same name will be attributed to the same contact. Message counts and key-based statistics + same name will be attributed to the same {nameOnly ? 'sender name' : 'contact'}. Stats below may be inaccurate. + {includeAliasNote && + ' Message counts below include messages attributed under the names listed in Also Known As.'}

); } +function MessageStatsSection({ + dmMessageCount, + channelMessageCount, + showDirectMessages = true, +}: { + dmMessageCount: number; + channelMessageCount: number; + showDirectMessages?: boolean; +}) { + if ((showDirectMessages ? dmMessageCount : 0) <= 0 && channelMessageCount <= 0) { + return null; + } + + return ( +
+ Messages +
+ {showDirectMessages && dmMessageCount > 0 && ( + + )} + {channelMessageCount > 0 && ( + + )} +
+
+ ); +} + +function MostActiveRoomsSection({ + rooms, + onNavigateToChannel, +}: { + rooms: ContactActiveRoom[]; + onNavigateToChannel?: (channelKey: string) => void; +}) { + if (rooms.length === 0) { + return null; + } + + return ( +
+ Most Active Rooms +
+ {rooms.map((room) => ( +
+ onNavigateToChannel?.(room.channel_key)} + > + {room.channel_name.startsWith('#') || room.channel_name === 'Public' + ? room.channel_name + : `#${room.channel_name}`} + + + {room.message_count.toLocaleString()} msg + {room.message_count !== 1 ? 's' : ''} + +
+ ))} +
+
+ ); +} + function InfoItem({ label, value }: { label: string; value: ReactNode }) { return (
diff --git a/frontend/src/test/contactInfoPane.test.tsx b/frontend/src/test/contactInfoPane.test.tsx index 65c83fc..6dfacdf 100644 --- a/frontend/src/test/contactInfoPane.test.tsx +++ b/frontend/src/test/contactInfoPane.test.tsx @@ -2,15 +2,17 @@ import { render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { ContactInfoPane } from '../components/ContactInfoPane'; -import type { Contact, ContactDetail } from '../types'; +import type { Contact, ContactDetail, NameOnlyContactDetail } from '../types'; -const { getContactDetail } = vi.hoisted(() => ({ +const { getContactDetail, getNameOnlyContactDetail } = vi.hoisted(() => ({ getContactDetail: vi.fn(), + getNameOnlyContactDetail: vi.fn(), })); vi.mock('../api', () => ({ api: { getContactDetail, + getNameOnlyContactDetail, }, })); @@ -54,7 +56,7 @@ function createContact(overrides: Partial = {}): Contact { }; } -function createDetail(contact: Contact): ContactDetail { +function createDetail(contact: Contact, overrides: Partial = {}): ContactDetail { return { contact, name_history: [], @@ -64,6 +66,18 @@ function createDetail(contact: Contact): ContactDetail { advert_paths: [], advert_frequency: null, nearest_repeaters: [], + ...overrides, + }; +} + +function createNameOnlyDetail( + overrides: Partial = {} +): NameOnlyContactDetail { + return { + name: 'Mystery', + channel_message_count: 0, + most_active_rooms: [], + ...overrides, }; } @@ -79,6 +93,7 @@ const baseProps = { describe('ContactInfoPane', () => { beforeEach(() => { getContactDetail.mockReset(); + getNameOnlyContactDetail.mockReset(); }); it('shows hop width when contact has a stored path hash mode', async () => { @@ -87,7 +102,7 @@ describe('ContactInfoPane', () => { render(); - await screen.findByText('Alice'); + await screen.findByText(contact.public_key); await waitFor(() => { expect(screen.getByText('Hop Width')).toBeInTheDocument(); expect(screen.getByText('2-byte IDs')).toBeInTheDocument(); @@ -127,4 +142,55 @@ describe('ContactInfoPane', () => { expect(screen.getByText('1 hop')).toBeInTheDocument(); }); }); + + it('loads name-only channel stats and most active rooms', async () => { + getNameOnlyContactDetail.mockResolvedValue( + createNameOnlyDetail({ + name: 'Mystery', + channel_message_count: 4, + most_active_rooms: [ + { + channel_key: 'ab'.repeat(16), + channel_name: '#ops', + message_count: 3, + }, + ], + }) + ); + + render(); + + await screen.findByText('Mystery'); + await waitFor(() => { + expect(getNameOnlyContactDetail).toHaveBeenCalledWith('Mystery'); + expect(screen.getByText('Messages')).toBeInTheDocument(); + expect(screen.getByText('Channel Messages')).toBeInTheDocument(); + expect(screen.getByText('4')).toBeInTheDocument(); + expect(screen.getByText('Most Active Rooms')).toBeInTheDocument(); + expect(screen.getByText('#ops')).toBeInTheDocument(); + expect(screen.getByText(/same sender name/i)).toBeInTheDocument(); + }); + }); + + it('shows alias note in the channel attribution warning for keyed contacts', async () => { + const contact = createContact(); + getContactDetail.mockResolvedValue( + createDetail(contact, { + name_history: [ + { name: 'Alice', first_seen: 1000, last_seen: 2000 }, + { name: 'AliceOld', first_seen: 900, last_seen: 999 }, + ], + }) + ); + + render(); + + await screen.findByText(contact.public_key); + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Also Known As' })).toBeInTheDocument(); + expect( + screen.getByText(/include messages attributed under the names listed in Also Known As/i) + ).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a42af49..5cf11e0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -125,6 +125,12 @@ export interface ContactDetail { nearest_repeaters: NearestRepeater[]; } +export interface NameOnlyContactDetail { + name: string; + channel_message_count: number; + most_active_rooms: ContactActiveRoom[]; +} + export interface Channel { key: string; name: string; diff --git a/tests/test_contacts_router.py b/tests/test_contacts_router.py index bc5e543..2561f86 100644 --- a/tests/test_contacts_router.py +++ b/tests/test_contacts_router.py @@ -358,6 +358,60 @@ class TestContactDetail: assert repeater["name"] == "Relay1" assert repeater["heard_count"] == 2 + +class TestNameOnlyContactDetail: + """Test GET /api/contacts/name-detail.""" + + @pytest.mark.asyncio + async def test_name_detail_returns_channel_stats(self, test_db, client): + chan_a = "11" * 16 + chan_b = "22" * 16 + + await MessageRepository.create( + msg_type="CHAN", + text="Mystery: hi", + conversation_key=chan_a, + sender_timestamp=1000, + received_at=1000, + sender_name="Mystery", + ) + await MessageRepository.create( + msg_type="CHAN", + text="Mystery: hello", + conversation_key=chan_a, + sender_timestamp=1001, + received_at=1001, + sender_name="Mystery", + ) + await MessageRepository.create( + msg_type="CHAN", + text="Mystery: ping", + conversation_key=chan_b, + sender_timestamp=1002, + received_at=1002, + sender_name="Mystery", + ) + + response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"}) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Mystery" + assert data["channel_message_count"] == 3 + assert len(data["most_active_rooms"]) == 2 + assert data["most_active_rooms"][0]["channel_key"] == chan_a + assert data["most_active_rooms"][0]["message_count"] == 2 + + @pytest.mark.asyncio + async def test_name_detail_with_no_activity_returns_empty(self, test_db, client): + response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"}) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Mystery" + assert data["channel_message_count"] == 0 + assert data["most_active_rooms"] == [] + @pytest.mark.asyncio async def test_detail_nearest_repeaters_use_full_multibyte_next_hop(self, test_db, client): """Nearest repeater resolution should distinguish multi-byte hops with the same first byte."""