kuamail/src/components/ThreadList.tsx

194 lines
7.2 KiB
TypeScript

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<HTMLDivElement>(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 <ThreadListSkeleton />;
if (!threads.length) return (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gmail-text-secondary text-mail-sm">
<div className="w-12 h-12 rounded-full bg-gmail-surface flex items-center justify-center mb-2">
<MailOpen size={22} className="text-gmail-text-muted" />
</div>
<p>No conversations</p>
</div>
);
return (
<div ref={parentRef} className="h-full overflow-y-auto">
{checkedThreadIds.size > 0 && (
<div className="sticky top-0 z-20 flex items-center gap-1 px-4 py-1.5 bg-gmail-blue-focus border-b border-gmail-border">
<button
onClick={clearChecks}
title="Clear selection"
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-black/10 transition-colors"
>
<X size={18} className="text-gmail-text-secondary" />
</button>
<span className="text-mail-sm text-gmail-text-primary font-medium px-2">
{checkedThreadIds.size} selected
</span>
<div className="w-px h-5 bg-gmail-border mx-1" />
<BulkAction onClick={() => bulkAction('archive')} title="Archive">
<Archive size={18} />
</BulkAction>
<BulkAction onClick={() => bulkAction('trash')} title="Delete">
<Trash2 size={18} />
</BulkAction>
<BulkAction onClick={() => bulkAction('read')} title="Mark as read">
<MailOpen size={18} />
</BulkAction>
<BulkAction onClick={() => {}} title="Snooze">
<Clock size={18} />
</BulkAction>
</div>
)}
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vi) => {
if (vi.index >= threads.length) {
return (
<div
key="loader"
style={{ position: 'absolute', top: vi.start, left: 0, right: 0, height: vi.size }}
className="flex items-center justify-center"
>
<span className="text-gmail-text-muted text-mail-sm">Loading</span>
</div>
);
}
const thread = threads[vi.index];
return (
<div
key={thread.id}
style={{ position: 'absolute', top: vi.start, left: 0, right: 0, height: vi.size }}
>
<ThreadRow
thread={thread}
isSelected={selectedThreadId === thread.id}
isChecked={checkedThreadIds.has(thread.id)}
onSelect={() => 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)}
/>
</div>
);
})}
</div>
</div>
);
}
function BulkAction({
onClick, title, children,
}: { onClick: () => void; title: string; children: React.ReactNode }) {
return (
<button
onClick={onClick}
title={title}
className="w-9 h-9 flex items-center justify-center rounded-full text-gmail-text-secondary hover:bg-black/10 hover:text-gmail-text-primary transition-colors"
>
{children}
</button>
);
}
function ThreadListSkeleton() {
return (
<div>
{Array.from({ length: 16 }).map((_, i) => (
<div key={i} className="flex items-center gap-3 pl-6 pr-4 h-10 border-b border-gmail-border/60 animate-pulse">
<div className="w-4 h-4 rounded bg-gray-200 flex-none" />
<div className="w-4 h-4 rounded bg-gray-100 flex-none" />
<div className="w-6 h-6 rounded-full bg-gray-200 flex-none" />
<div className="w-32 h-3 rounded bg-gray-200 flex-none" />
<div className="flex-1 h-3 rounded bg-gray-100" />
<div className="w-12 h-3 rounded bg-gray-100 flex-none" />
</div>
))}
</div>
);
}