import { useVirtualizer } from '@tanstack/react-virtual'; import { useRef, useCallback } from 'react'; import { useInfiniteQuery } from '@tanstack/react-query'; import { api, type Thread } from '@/api/client'; import { useUI } from '@/store/ui'; import { ThreadRow } from './ThreadRow'; import { queueMutation } from '@/sync/engine'; import { Archive, Trash2, MailOpen, Clock, X } from 'lucide-react'; type Props = { folderId: string }; export function ThreadList({ folderId }: Props) { const { accountId, selectedThreadId, selectThread, checkedThreadIds, toggleCheck, clearChecks, } = useUI(); const parentRef = useRef(null); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending } = useInfiniteQuery({ queryKey: ['threads', folderId], queryFn: ({ pageParam }) => api.threads.list(folderId, { limit: 50, before: pageParam as string | undefined }), getNextPageParam: (last) => last.next_cursor ?? undefined, initialPageParam: undefined as string | undefined, enabled: !!folderId, }); const threads = data?.pages.flatMap((p) => p.threads) ?? []; const virtualizer = useVirtualizer({ count: threads.length + (hasNextPage ? 1 : 0), getScrollElement: () => parentRef.current, estimateSize: () => 40, overscan: 12, onChange: (v) => { const lastItem = v.getVirtualItems().at(-1); if (!lastItem) return; if (lastItem.index >= threads.length - 1 && hasNextPage && !isFetchingNextPage) { fetchNextPage(); } }, }); const handleStar = useCallback((e: React.MouseEvent, thread: Thread) => { e.stopPropagation(); if (!accountId) return; queueMutation(accountId, 'flag', thread.id, { flagged: !thread.has_flagged }); }, [accountId]); const handleCheck = useCallback((e: React.MouseEvent, threadId: string) => { e.stopPropagation(); toggleCheck(threadId); }, [toggleCheck]); const handleArchive = useCallback((_: React.MouseEvent, thread: Thread) => { if (!accountId) return; queueMutation(accountId, 'move', thread.id, { folder_special_use: 'archive' }); }, [accountId]); const handleDelete = useCallback((_: React.MouseEvent, thread: Thread) => { if (!accountId) return; queueMutation(accountId, 'move', thread.id, { folder_special_use: 'trash' }); }, [accountId]); const handleMarkRead = useCallback((_: React.MouseEvent, thread: Thread) => { if (!accountId) return; queueMutation(accountId, 'flag', thread.id, { seen: thread.unread_count > 0 }); }, [accountId]); const handleSnooze = useCallback((_: React.MouseEvent, _thread: Thread) => { // Snooze: Phase 3 — needs server support. No-op for now. }, []); const bulkAction = (op: 'archive' | 'trash' | 'read') => { if (!accountId) return; for (const id of checkedThreadIds) { if (op === 'archive') queueMutation(accountId, 'move', id, { folder_special_use: 'archive' }); else if (op === 'trash') queueMutation(accountId, 'move', id, { folder_special_use: 'trash' }); else if (op === 'read') queueMutation(accountId, 'flag', id, { seen: true }); } clearChecks(); }; if (isPending) return ; if (!threads.length) return (

No conversations

); return (
{checkedThreadIds.size > 0 && (
{checkedThreadIds.size} selected
bulkAction('archive')} title="Archive"> bulkAction('trash')} title="Delete"> bulkAction('read')} title="Mark as read"> {}} title="Snooze">
)}
{virtualizer.getVirtualItems().map((vi) => { if (vi.index >= threads.length) { return (
Loading…
); } const thread = threads[vi.index]; return (
selectThread(thread.id)} onCheck={(e) => handleCheck(e, thread.id)} onStar={(e) => handleStar(e, thread)} onArchive={(e) => handleArchive(e, thread)} onDelete={(e) => handleDelete(e, thread)} onMarkRead={(e) => handleMarkRead(e, thread)} onSnooze={(e) => handleSnooze(e, thread)} />
); })}
); } function BulkAction({ onClick, title, children, }: { onClick: () => void; title: string; children: React.ReactNode }) { return ( ); } function ThreadListSkeleton() { return (
{Array.from({ length: 16 }).map((_, i) => (
))}
); }