194 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|