mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 09:22:04 +02:00
Enrich contact no-key info pane with first-in-use date
This commit is contained in:
@@ -5,6 +5,7 @@ import type {
|
||||
ChannelDetail,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactAnalytics,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
@@ -114,6 +115,12 @@ export const api = {
|
||||
),
|
||||
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
},
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
getNameOnlyContactDetail: (name: string) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,17 +2,15 @@ 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, NameOnlyContactDetail } from '../types';
|
||||
import type { Contact, ContactAnalytics } from '../types';
|
||||
|
||||
const { getContactDetail, getNameOnlyContactDetail } = vi.hoisted(() => ({
|
||||
getContactDetail: vi.fn(),
|
||||
getNameOnlyContactDetail: vi.fn(),
|
||||
const { getContactAnalytics } = vi.hoisted(() => ({
|
||||
getContactAnalytics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getContactDetail,
|
||||
getNameOnlyContactDetail,
|
||||
getContactAnalytics,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -56,27 +54,33 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
};
|
||||
}
|
||||
|
||||
function createDetail(contact: Contact, overrides: Partial<ContactDetail> = {}): ContactDetail {
|
||||
function createAnalytics(
|
||||
contact: Contact | null,
|
||||
overrides: Partial<ContactAnalytics> = {}
|
||||
): ContactAnalytics {
|
||||
return {
|
||||
lookup_type: contact ? 'contact' : 'name',
|
||||
name: contact?.name ?? 'Mystery',
|
||||
contact,
|
||||
name_first_seen_at: null,
|
||||
name_history: [],
|
||||
dm_message_count: 0,
|
||||
channel_message_count: 0,
|
||||
includes_direct_messages: Boolean(contact),
|
||||
most_active_rooms: [],
|
||||
advert_paths: [],
|
||||
advert_frequency: null,
|
||||
nearest_repeaters: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createNameOnlyDetail(
|
||||
overrides: Partial<NameOnlyContactDetail> = {}
|
||||
): NameOnlyContactDetail {
|
||||
return {
|
||||
name: 'Mystery',
|
||||
channel_message_count: 0,
|
||||
most_active_rooms: [],
|
||||
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 3600,
|
||||
last_24h_count: 0,
|
||||
last_week_average: 0,
|
||||
all_time_average: 0,
|
||||
})),
|
||||
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 604800,
|
||||
message_count: 0,
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -92,13 +96,12 @@ const baseProps = {
|
||||
|
||||
describe('ContactInfoPane', () => {
|
||||
beforeEach(() => {
|
||||
getContactDetail.mockReset();
|
||||
getNameOnlyContactDetail.mockReset();
|
||||
getContactAnalytics.mockReset();
|
||||
});
|
||||
|
||||
it('shows hop width when contact has a stored path hash mode', async () => {
|
||||
const contact = createContact({ out_path_hash_mode: 1 });
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
@@ -111,7 +114,7 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
it('does not show hop width for flood-routed contacts', async () => {
|
||||
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
@@ -130,7 +133,7 @@ describe('ContactInfoPane', () => {
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
});
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
@@ -144,9 +147,11 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
|
||||
it('loads name-only channel stats and most active rooms', async () => {
|
||||
getNameOnlyContactDetail.mockResolvedValue(
|
||||
createNameOnlyDetail({
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, {
|
||||
lookup_type: 'name',
|
||||
name: 'Mystery',
|
||||
name_first_seen_at: 1_699_999_000,
|
||||
channel_message_count: 4,
|
||||
most_active_rooms: [
|
||||
{
|
||||
@@ -155,6 +160,16 @@ describe('ContactInfoPane', () => {
|
||||
message_count: 3,
|
||||
},
|
||||
],
|
||||
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 3600,
|
||||
last_24h_count: index === 23 ? 2 : 0,
|
||||
last_week_average: index === 23 ? 1.5 : 0,
|
||||
all_time_average: index === 23 ? 1.2 : 0,
|
||||
})),
|
||||
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 604800,
|
||||
message_count: index === 25 ? 4 : 0,
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -162,20 +177,26 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
await screen.findByText('Mystery');
|
||||
await waitFor(() => {
|
||||
expect(getNameOnlyContactDetail).toHaveBeenCalledWith('Mystery');
|
||||
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
|
||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
).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, {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(contact, {
|
||||
name_history: [
|
||||
{ name: 'Alice', first_seen: 1000, last_seen: 2000 },
|
||||
{ name: 'AliceOld', first_seen: 900, last_seen: 999 },
|
||||
@@ -189,7 +210,9 @@ describe('ContactInfoPane', () => {
|
||||
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)
|
||||
screen.getByText(
|
||||
/may include messages previously attributed under names shown in Also Known As/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,35 @@ export interface NameOnlyContactDetail {
|
||||
most_active_rooms: ContactActiveRoom[];
|
||||
}
|
||||
|
||||
export interface ContactAnalyticsHourlyBucket {
|
||||
bucket_start: number;
|
||||
last_24h_count: number;
|
||||
last_week_average: number;
|
||||
all_time_average: number;
|
||||
}
|
||||
|
||||
export interface ContactAnalyticsWeeklyBucket {
|
||||
bucket_start: number;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ContactAnalytics {
|
||||
lookup_type: 'contact' | 'name';
|
||||
name: string;
|
||||
contact: Contact | null;
|
||||
name_first_seen_at: number | null;
|
||||
name_history: ContactNameHistory[];
|
||||
dm_message_count: number;
|
||||
channel_message_count: number;
|
||||
includes_direct_messages: boolean;
|
||||
most_active_rooms: ContactActiveRoom[];
|
||||
advert_paths: ContactAdvertPath[];
|
||||
advert_frequency: number | null;
|
||||
nearest_repeaters: NearestRepeater[];
|
||||
hourly_activity: ContactAnalyticsHourlyBucket[];
|
||||
weekly_activity: ContactAnalyticsWeeklyBucket[];
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
key: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user