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>
  );
}