mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Enrich names-based contact pane a bit
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
NameOnlyContactDetail,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RepeaterAclResponse,
|
||||
@@ -115,6 +116,8 @@ export const api = {
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
getNameOnlyContactDetail: (name: string) =>
|
||||
fetchJson<NameOnlyContactDetail>(`/contacts/name-detail?name=${encodeURIComponent(name)}`),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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<number, string> = {
|
||||
0: 'Unknown',
|
||||
@@ -67,6 +74,7 @@ export function ContactInfoPane({
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
const [detail, setDetail] = useState<ContactDetail | null>(null);
|
||||
const [nameOnlyDetail, setNameOnlyDetail] = useState<NameOnlyContactDetail | null>(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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Block by name toggle */}
|
||||
{onToggleBlockedName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
@@ -165,6 +198,25 @@ export function ContactInfoPane({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
nameOnly
|
||||
includeAliasNote={false}
|
||||
className="border-b border-border mx-0 my-0 rounded-none px-5 py-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={0}
|
||||
channelMessageCount={nameOnlyDetail?.channel_message_count ?? 0}
|
||||
showDirectMessages={false}
|
||||
/>
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={nameOnlyDetail?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : loading && !detail ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -212,8 +264,6 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -340,79 +390,6 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Stats */}
|
||||
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{detail.dm_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Direct Messages"
|
||||
value={detail.dm_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
{detail.channel_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Channel Messages"
|
||||
value={detail.channel_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Active Rooms */}
|
||||
{detail && detail.most_active_rooms.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.most_active_rooms.map((room) => (
|
||||
<div
|
||||
key={room.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{detail && detail.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
@@ -435,7 +412,7 @@ export function ContactInfoPane({
|
||||
|
||||
{/* Advert Paths */}
|
||||
{detail && detail.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.advert_paths.map((p) => (
|
||||
@@ -454,6 +431,39 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
includeAliasNote={Boolean(detail && detail.name_history.length > 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={detail?.dm_message_count ?? 0}
|
||||
channelMessageCount={detail?.channel_message_count ?? 0}
|
||||
/>
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={detail?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -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 (
|
||||
<div className="mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<div className={className}>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
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.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageStatsSection({
|
||||
dmMessageCount,
|
||||
channelMessageCount,
|
||||
showDirectMessages = true,
|
||||
}: {
|
||||
dmMessageCount: number;
|
||||
channelMessageCount: number;
|
||||
showDirectMessages?: boolean;
|
||||
}) {
|
||||
if ((showDirectMessages ? dmMessageCount : 0) <= 0 && channelMessageCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{showDirectMessages && dmMessageCount > 0 && (
|
||||
<InfoItem label="Direct Messages" value={dmMessageCount.toLocaleString()} />
|
||||
)}
|
||||
{channelMessageCount > 0 && (
|
||||
<InfoItem label="Channel Messages" value={channelMessageCount.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MostActiveRoomsSection({
|
||||
rooms,
|
||||
onNavigateToChannel,
|
||||
}: {
|
||||
rooms: ContactActiveRoom[];
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}) {
|
||||
if (rooms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{rooms.map((room) => (
|
||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -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> = {}): Contact {
|
||||
};
|
||||
}
|
||||
|
||||
function createDetail(contact: Contact): ContactDetail {
|
||||
function createDetail(contact: Contact, overrides: Partial<ContactDetail> = {}): 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> = {}
|
||||
): 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(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
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(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
|
||||
|
||||
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(<ContactInfoPane {...baseProps} contactKey={contact.public_key} fromChannel />);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user