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';