Enrich names-based contact pane a bit

This commit is contained in:
Jack Kingsman
2026-03-11 15:57:29 -07:00
parent e7d1f28076
commit 93369f8d64
8 changed files with 369 additions and 86 deletions
+3
View File
@@ -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',
+173 -82
View File
@@ -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)} &ndash; {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)} &ndash; {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>
+70 -4
View File
@@ -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();
});
});
});
+6
View File
@@ -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;