email-tracking-indicator.tsx12.1 KBView on GitHub import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Eye, MousePointerClick, ExternalLink, User } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { formatDistanceToNow } from 'date-fns';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
interface TrackingEvent {
id: string;
eventType: string;
ipAddress: string | null;
userAgent: string | null;
country: string | null;
city: string | null;
region: string | null;
clientType: string | null;
isReliable: boolean;
linkUrl: string | null;
linkIndex: number | null;
occurredAt: Date;
}
interface ViewerGroup {
/** Stable label like "Person 1", "Person 2" */
label: string;
/** Best-available location string for this viewer */
location: string;
/** Client type label */
client: string;
/** All events from this viewer, oldest first */
events: TrackingEvent[];
}
interface EmailTrackingIndicatorProps {
trackingId: string;
openCount: number;
clickCount: number;
firstOpenedAt: Date | null;
lastOpenedAt: Date | null;
firstClickedAt: Date | null;
openTrackingEnabled: boolean;
linkTrackingEnabled: boolean;
toEmails?: string[];
}
function formatLocation(event: TrackingEvent): string {
if (event.city && event.region && event.country) return `${event.city}, ${event.region}, ${event.country}`;
if (event.city && event.country) return `${event.city}, ${event.country}`;
if (event.region && event.country) return `${event.region}, ${event.country}`;
if (event.country) return event.country;
return '';
}
function formatClientType(clientType: string | null): string {
if (!clientType) return '';
if (clientType === 'gmail_proxy') return 'Gmail';
if (clientType === 'apple_privacy_proxy') return 'Apple Mail';
return 'Direct';
}
function truncateUrl(url: string, maxLen = 40): string {
try {
const parsed = new URL(url);
const display = parsed.hostname + parsed.pathname;
return display.length > maxLen ? display.slice(0, maxLen) + '…' : display;
} catch {
return url.length > maxLen ? url.slice(0, maxLen) + '…' : url;
}
}
/**
* Groups events by unique viewer (IP address), filters out self-opens,
* and assigns stable "Person N" labels ordered by first appearance.
*/
function groupEventsByViewer(events: TrackingEvent[], viewerIp: string | null): ViewerGroup[] {
const ipToEvents = new Map<string, TrackingEvent[]>();
const ipOrder: string[] = [];
// Sort all events oldest-first so Person numbering is by first appearance
const sorted = [...events].sort(
(a, b) => new Date(a.occurredAt).getTime() - new Date(b.occurredAt).getTime(),
);
for (const event of sorted) {
const key=[redacted] || 'unknown';
// Filter self-opens: skip events whose IP matches the current viewer's session IP
if (viewerIp && key === viewerIp) continue;
if (!ipToEvents.has(key)) {
ipToEvents.set(key, []);
ipOrder.push(key);
}
ipToEvents.get(key)!.push(event);
}
return ipOrder.map((ip, idx): ViewerGroup => {
const groupEvents = ipToEvents.get(ip)!;
// Use the most detailed location from any event in the group
const bestLocationEvent = groupEvents.find((e) => e.city) || groupEvents.find((e) => e.country) || groupEvents[0];
const location = formatLocation(bestLocationEvent);
const client = formatClientType(bestLocationEvent.clientType);
return {
label: `Person ${idx + 1}`,
location,
client,
events: groupEvents,
};
});
}
export function EmailTrackingIndicator({
trackingId,
openCount,
clickCount,
lastOpenedAt,
openTrackingEnabled,
linkTrackingEnabled,
}: EmailTrackingIndicatorProps) {
const [isOpen, setIsOpen] = useState(false);
const trpc = useTRPC();
// Whether the pill has anything to show. Computed here but NOT returned on: every hook below
// has to run on every render. These props come off the thread query, so an indicator mounted
// for a freshly sent message (openCount 0) flips to openCount > 0 the moment the recipient
// opens the mail. Guarding above the hooks turned that flip into React #310 ("Rendered more
// hooks than during the previous render"), which took the whole route down.
// See apps/mail/docs/bug-email-tracking-indicator-conditional-hooks.md.
const hasActivity = openCount > 0 || clickCount > 0;
const isVisible = (openTrackingEnabled || linkTrackingEnabled) && hasActivity;
// Lazy load detailed events only when popover is opened
const { data, isLoading } = useQuery(
trpc.deliverability.getEvents.queryOptions(
{ trackingId },
{ enabled: isOpen },
),
);
// Group events by unique viewer, filtering out self-opens
const viewerGroups = useMemo(
() => (data?.events ? groupEventsByViewer(data.events, data.viewerIp ?? null) : []),
[data],
);
// Recount opens/clicks after filtering self-opens
const filteredOpenCount = useMemo(
() => viewerGroups.reduce((sum, g) => sum + g.events.filter((e) => e.eventType === 'opened').length, 0),
[viewerGroups],
);
const filteredClickCount = useMemo(
() => viewerGroups.reduce((sum, g) => sum + g.events.filter((e) => e.eventType === 'clicked').length, 0),
[viewerGroups],
);
const uniqueViewerCount = viewerGroups.length;
// Safe here — every hook above has already run.
if (!isVisible) {
return null;
}
// Build badge label
const badgeLabel = lastOpenedAt
? `Opened ${formatDistanceToNow(new Date(lastOpenedAt), { addSuffix: true })}`
: openCount > 0
? `Opened ${openCount} time${openCount !== 1 ? 's' : ''}`
: `${clickCount} click${clickCount !== 1 ? 's' : ''}`;
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
<button
className={cn(
'inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs font-medium transition-colors whitespace-nowrap',
'bg-emerald-50 text-emerald-700 hover:bg-emerald-100',
'dark:bg-emerald-950/40 dark:text-emerald-400 dark:hover:bg-emerald-950/60',
)}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setIsOpen(!isOpen);
}}
>
<Eye className="h-3 w-3" />
<span>{badgeLabel}</span>
</button>
</PopoverTrigger>
<PopoverContent
side="bottom"
align="start"
className="w-[360px] p-0"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="border-b px-3 py-2">
<div className="flex items-center gap-3 text-xs font-medium">
{openTrackingEnabled && (
<span className="flex items-center gap-1 text-emerald-600 dark:text-emerald-400">
<Eye className="h-3 w-3" />
{isLoading ? openCount : filteredOpenCount} open{(isLoading ? openCount : filteredOpenCount) !== 1 ? 's' : ''}
</span>
)}
{linkTrackingEnabled && (isLoading ? clickCount : filteredClickCount) > 0 && (
<span className="flex items-center gap-1 text-blue-600 dark:text-blue-400">
<MousePointerClick className="h-3 w-3" />
{isLoading ? clickCount : filteredClickCount} click{(isLoading ? clickCount : filteredClickCount) !== 1 ? 's' : ''}
</span>
)}
{!isLoading && uniqueViewerCount > 0 && (
<span className="flex items-center gap-1 text-muted-foreground">
<User className="h-3 w-3" />
{uniqueViewerCount} viewer{uniqueViewerCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Events grouped by viewer */}
<div className="max-h-[300px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-6 text-xs text-muted-foreground">
Loading events…
</div>
) : viewerGroups.length === 0 ? (
<div className="flex items-center justify-center py-6 text-xs text-muted-foreground">
No events recorded
</div>
) : (
<div className="divide-y">
{viewerGroups.map((group) => (
<div key=[redacted] className="py-1.5">
{/* Viewer header */}
<div className="flex items-center gap-2 px-3 py-1">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<User className="h-3 w-3" />
</div>
<div className="flex min-w-0 flex-1 items-baseline gap-1.5">
<span className="text-xs font-semibold text-foreground">{group.label}</span>
{(group.location || group.client) && (
<span className="truncate text-xs text-muted-foreground">
{[group.location, group.client].filter(Boolean).join(' · ')}
</span>
)}
</div>
</div>
{/* Viewer's events */}
{group.events.map((event) => {
const isOpenEvent = event.eventType === 'opened';
const timeAgo = formatDistanceToNow(new Date(event.occurredAt), {
addSuffix: true,
});
return (
<div key=[redacted] className="flex items-start gap-2.5 py-1 pl-10 pr-3">
{/* Icon */}
<div
className={cn(
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full',
isOpenEvent
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-950 dark:text-emerald-400'
: 'bg-blue-100 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
)}
>
{isOpenEvent ? (
<Eye className="h-2.5 w-2.5" />
) : (
<ExternalLink className="h-2.5 w-2.5" />
)}
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-1.5">
<span className="text-xs font-medium text-foreground">
{isOpenEvent ? 'Opened' : 'Clicked link'}
</span>
<span className="text-xs text-muted-foreground">{timeAgo}</span>
</div>
{/* Link URL for click events */}
{!isOpenEvent && event.linkUrl && (
<a
href={event.linkUrl}
target="_blank"
rel="noopener noreferrer"
className="mt-0.5 block truncate text-xs text-blue-600 hover:underline dark:text-blue-400"
onClick={(e) => e.stopPropagation()}
>
{truncateUrl(event.linkUrl)}
</a>
)}
{/* Unreliable warning */}
{!event.isReliable && (
<div className="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
May be automated (pre-fetch)
</div>
)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
{/* Footer note */}
<div className="border-t px-3 py-1.5 text-[10px] text-muted-foreground">
Open tracking is approximate due to email client privacy features.
</div>
</PopoverContent>
</Popover>
);
}