EventMentionList.tsx4.8 KBView on GitHub
'use client';

import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
  Mail,
  MessageSquare,
  Calendar,
  Phone,
  StickyNote,
  Sparkles,
  FileQuestion,
} from 'lucide-react';
import { cn } from '@/lib/utils';

export interface EventMentionItem {
  id: string;
  eventType: string;
  title: string;
  summary: string | null;
  occurredAt: Date | string;
  conversationId: string | null;
  conversationName: string | null;
  companyName: string | null;
  companyLogoUrl: string | null;
}

interface EventMentionListProps {
  items: EventMentionItem[];
  command: (item: EventMentionItem) => void;
  loading: boolean;
}

export interface EventMentionListRef {
  onKeyDown: (data: { event: KeyboardEvent }) => boolean;
}

const ICON_BY_TYPE: Record<string, React.ComponentType<{ className?: string }>> = {
  email: Mail,
  slack: MessageSquare,
  meeting: Calendar,
  call: Phone,
  note: StickyNote,
  custom: Sparkles,
};

function formatRelative(when: Date | string): string {
  const d = typeof when === 'string' ? new Date(when) : when;
  const diff = Date.now() - d.getTime();
  const sec = Math.floor(diff / 1000);
  if (sec < 60) return 'just now';
  const min = Math.floor(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `${hr}h ago`;
  const day = Math.floor(hr / 24);
  if (day < 30) return `${day}d ago`;
  const mo = Math.floor(day / 30);
  if (mo < 12) return `${mo}mo ago`;
  return `${Math.floor(mo / 12)}y ago`;
}

/**
 * Suggestion popup body for the `{{` event mention. Renders one row per event
 * with a type-specific icon, the event title, and a sub-label combining
 * conversation/company context with a relative timestamp.
 */
export const EventMentionList = forwardRef<EventMentionListRef, EventMentionListProps>(
  ({ items, command, loading }, ref) => {
    const [selectedIndex, setSelectedIndex] = useState(0);
    const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);

    useEffect(() => {
      setSelectedIndex(0);
    }, [items]);

    useEffect(() => {
      itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest' });
    }, [selectedIndex]);

    useImperativeHandle(ref, () => ({
      onKeyDown: ({ event }) => {
        if (event.key === 'ArrowUp') {
          setSelectedIndex(
            (i) => (i - 1 + Math.max(items.length, 1)) % Math.max(items.length, 1),
          );
          return true;
        }
        if (event.key === 'ArrowDown') {
          setSelectedIndex((i) => (i + 1) % Math.max(items.length, 1));
          return true;
        }
        if (event.key === 'Enter') {
          const item = items[selectedIndex];
          if (item) {
            command(item);
            return true;
          }
          return false;
        }
        return false;
      },
    }));

    if (loading && items.length === 0) {
      return (
        <div className="text-muted-foreground z-99999 w-80 rounded-xl border border-[#E7E7E7] bg-white p-2 text-xs shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
          Searching…
        </div>
      );
    }

    if (items.length === 0) {
      return (
        <div className="text-muted-foreground z-99999 w-80 rounded-xl border border-[#E7E7E7] bg-white p-2 text-xs shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
          No events found
        </div>
      );
    }

    return (
      <div className="z-99999 max-h-72 w-80 overflow-y-auto rounded-xl border border-[#E7E7E7] bg-white p-1 shadow-lg dark:border-[#252525] dark:bg-[#1A1A1A]">
        {items.map((item, index) => {
          const Icon = ICON_BY_TYPE[item.eventType] ?? FileQuestion;
          const subLabel = [item.conversationName ?? item.companyName, formatRelative(item.occurredAt)]
            .filter(Boolean)
            .join(' · ');
          return (
            <button
              key=[redacted]
              ref={(el) => {
                itemRefs.current[index] = el;
              }}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => command(item)}
              className={cn(
                'flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition-colors',
                index === selectedIndex
                  ? 'bg-gray-100 dark:bg-[#252525]'
                  : 'hover:bg-gray-50 dark:hover:bg-[#1E1E1E]',
              )}
            >
              <Icon className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
              <div className="flex min-w-0 flex-1 flex-col">
                <span className="truncate">{item.title}</span>
                {subLabel ? (
                  <span className="text-muted-foreground truncate text-xs">{subLabel}</span>
                ) : null}
              </div>
            </button>
          );
        })}
      </div>
    );
  },
);

EventMentionList.displayName = 'EventMentionList';