Enrich contact no-key info pane with first-in-use date

This commit is contained in:
Jack Kingsman
2026-03-11 16:19:10 -07:00
parent 93369f8d64
commit 0c35601af3
8 changed files with 749 additions and 164 deletions
+260 -56
View File
@@ -20,9 +20,10 @@ import { toast } from './ui/sonner';
import type {
Contact,
ContactActiveRoom,
ContactDetail,
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
NameOnlyContactDetail,
RadioConfig,
} from '../types';
@@ -73,8 +74,7 @@ export function ContactInfoPane({
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [nameOnlyDetail, setNameOnlyDetail] = useState<NameOnlyContactDetail | null>(null);
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false);
// Get live contact data from contacts array (real-time via WS)
@@ -82,21 +82,26 @@ export function ContactInfoPane({
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
useEffect(() => {
if (!contactKey || isNameOnly) {
setDetail(null);
if (!contactKey) {
setAnalytics(null);
return;
}
let cancelled = false;
setAnalytics(null);
setLoading(true);
api
.getContactDetail(contactKey)
const request =
isNameOnly && nameOnlyValue
? api.getContactAnalytics({ name: nameOnlyValue })
: api.getContactAnalytics({ publicKey: contactKey });
request
.then((data) => {
if (!cancelled) setDetail(data);
if (!cancelled) setAnalytics(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch contact detail:', err);
console.error('Failed to fetch contact analytics:', err);
toast.error('Failed to load contact info');
}
})
@@ -106,37 +111,10 @@ export function ContactInfoPane({
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
}, [contactKey, isNameOnly, nameOnlyValue]);
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;
// Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null;
const distFromUs =
contact &&
@@ -165,9 +143,15 @@ export function ContactInfoPane({
{/* Name-only header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
<ContactAvatar
name={analytics?.name ?? nameOnlyValue}
publicKey={`name:${nameOnlyValue}`}
size={56}
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
<h2 className="text-lg font-semibold truncate">
{analytics?.name ?? nameOnlyValue}
</h2>
<p className="text-xs text-muted-foreground mt-1">
We have not heard an advertisement associated with this name, so we cannot
identify their key.
@@ -209,16 +193,29 @@ export function ContactInfoPane({
<MessageStatsSection
dmMessageCount={0}
channelMessageCount={nameOnlyDetail?.channel_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
showDirectMessages={false}
/>
{analytics?.name_first_seen_at && (
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<InfoItem
label="Name First In Use"
value={formatTime(analytics.name_first_seen_at)}
/>
</div>
</div>
)}
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={nameOnlyDetail?.most_active_rooms ?? []}
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : loading && !detail ? (
) : loading && !analytics && !contact ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
@@ -391,11 +388,11 @@ export function ContactInfoPane({
)}
{/* Nearest Repeaters */}
{detail && detail.nearest_repeaters.length > 0 && (
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{detail.nearest_repeaters.map((r) => (
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
@@ -411,11 +408,11 @@ export function ContactInfoPane({
)}
{/* Advert Paths */}
{detail && detail.advert_paths.length > 0 && (
{analytics && analytics.advert_paths.length > 0 && (
<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) => (
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
@@ -434,16 +431,16 @@ export function ContactInfoPane({
{fromChannel && (
<ChannelAttributionWarning
includeAliasNote={Boolean(detail && detail.name_history.length > 1)}
includeAliasNote={Boolean(analytics && analytics.name_history.length > 1)}
/>
)}
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
{analytics && analytics.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) => (
{analytics.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">
@@ -456,12 +453,14 @@ export function ContactInfoPane({
)}
<MessageStatsSection
dmMessageCount={detail?.dm_message_count ?? 0}
channelMessageCount={detail?.channel_message_count ?? 0}
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={detail?.most_active_rooms ?? []}
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
@@ -499,7 +498,7 @@ function ChannelAttributionWarning({
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.'}
' Historical counts below may include messages previously attributed under names shown in Also Known As.'}
</p>
</div>
);
@@ -576,6 +575,211 @@ function MostActiveRoomsSection({
);
}
function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | null }) {
if (!analytics) {
return null;
}
const hasHourlyActivity = analytics.hourly_activity.some(
(bucket) =>
bucket.last_24h_count > 0 || bucket.last_week_average > 0 || bucket.all_time_average > 0
);
const hasWeeklyActivity = analytics.weekly_activity.some((bucket) => bucket.message_count > 0);
if (!hasHourlyActivity && !hasWeeklyActivity) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border space-y-4">
{hasHourlyActivity && (
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ChartLegend
items={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
/>
<ActivityLineChart
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
]}
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
/>
</div>
)}
{hasWeeklyActivity && (
<div>
<SectionLabel>Messages Per Week</SectionLabel>
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
month: 'short',
day: 'numeric',
})
}
/>
</div>
)}
<p className="text-[11px] text-muted-foreground">
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
slots.
{!analytics.includes_direct_messages &&
' Name-only analytics include channel messages only.'}
</p>
</div>
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string }>;
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((entry) => {
const value = point[entry.key];
return typeof value === 'number' ? value : 0;
})
);
const maxValue = Math.max(1, ...allValues);
const tickIndices = Array.from(
new Set([
0,
Math.floor((points.length - 1) / 3),
Math.floor(((points.length - 1) * 2) / 3),
points.length - 1,
])
);
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
return `${x},${y}`;
})
.join(' ');
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<text
x={padding.left - 6}
y={y + 4}
fontSize="10"
textAnchor="end"
fill="hsl(var(--muted-foreground))"
>
{valueFormatter(value)}
</text>
</g>
);
})}
{series.map((entry) => (
<polyline
key={String(entry.key)}
fill="none"
stroke={entry.color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={buildPolyline(entry.key)}
/>
))}
{tickIndices.map((index) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>