"use client"; import { Email, ThreadGroup } from "@/lib/jmap/types"; import { ThreadListItem } from "./thread-list-item"; import { EmailContextMenu } from "./email-context-menu"; import { cn } from "@/lib/utils"; import { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { useEmailStore } from "@/stores/email-store"; import { useAuthStore } from "@/stores/auth-store"; import { useSettingsStore } from "@/stores/settings-store"; import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils"; import { useContextMenu } from "@/hooks/use-context-menu"; import { useTranslations } from "next-intl"; import { useVirtualizer } from "@tanstack/react-virtual"; import { SearchChips } from "@/components/search/search-chips"; import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils"; interface EmailListProps { emails: Email[]; selectedEmailId?: string; onEmailSelect?: (email: Email) => void; className?: string; isLoading?: boolean; onOpenConversation?: (thread: ThreadGroup) => void; onReply?: (email: Email) => void; onReplyAll?: (email: Email) => void; onForward?: (email: Email) => void; onMarkAsRead?: (email: Email, read: boolean) => void; onToggleStar?: (email: Email) => void; onDelete?: (email: Email) => void; onArchive?: (email: Email) => void; onSetColorTag?: (emailId: string, color: string | null) => void; onMoveToMailbox?: (emailId: string, mailboxId: string) => void; onMarkAsSpam?: (email: Email) => void; onUndoSpam?: (email: Email) => void; } export function EmailList({ emails, selectedEmailId, onEmailSelect, className, isLoading = false, onOpenConversation, onReply, onReplyAll, onForward, onMarkAsRead, onToggleStar, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, }: EmailListProps) { const t = useTranslations('email_list'); const { client } = useAuthStore(); const { selectedEmailIds, selectAllEmails, clearSelection, batchMarkAsRead, batchDelete, batchMoveToMailbox, batchMarkAsSpam, batchUndoSpam, loadMoreEmails, hasMoreEmails, isLoadingMore, totalEmails, mailboxes, selectedMailbox, expandedThreadIds, threadEmailsCache, isLoadingThread, toggleThreadExpansion, fetchThreadEmails, searchFilters, setSearchFilters, clearSearchFilters, advancedSearch, } = useEmailStore(); const threadGroups = useMemo(() => { const groups = groupEmailsByThread(emails); return sortThreadGroups(groups); }, [emails]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); const listDensity = useSettingsStore((state) => state.listDensity); const showPreview = useSettingsStore((state) => state.showPreview); const estimateSize = useCallback(() => { const base = { compact: 72, regular: 88, comfortable: 104 }[listDensity]; return showPreview ? base + 40 : base; }, [listDensity, showPreview]); const virtualizer = useVirtualizer({ count: threadGroups.length, getScrollElement: () => parentRef.current, estimateSize, overscan: 5, getItemKey: (index) => threadGroups[index]?.threadId ?? String(index), }); const LoadingSkeleton = () => (
{[...Array(8)].map((_, i) => (
))}
); const hasSelection = selectedEmailIds.size > 0; const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id)); const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; setIsProcessing(true); try { await batchMarkAsRead(client, read); } finally { setTimeout(() => setIsProcessing(false), 500); } }; const handleBatchDelete = async () => { if (!client || isProcessing || !confirm(`Delete ${selectedEmailIds.size} emails?`)) return; setIsProcessing(true); try { await batchDelete(client); } finally { setTimeout(() => setIsProcessing(false), 500); } }; const handleLoadMore = useCallback(() => { if (client && hasMoreEmails && !isLoadingMore && !isLoading) { loadMoreEmails(client); } }, [client, hasMoreEmails, isLoadingMore, isLoading, loadMoreEmails]); const handleToggleThreadExpansion = useCallback(async (threadId: string) => { const isExpanded = expandedThreadIds.has(threadId); if (!isExpanded && client) { toggleThreadExpansion(threadId); await fetchThreadEmails(client, threadId); } else { toggleThreadExpansion(threadId); } }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); // Range-based load more: trigger when last visible item is near the end const virtualItems = virtualizer.getVirtualItems(); const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; useEffect(() => { if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex >= threadGroups.length - 5) { handleLoadMore(); } }, [lastVirtualItemIndex, threadGroups.length, handleLoadMore]); // Scroll to the thread group containing the selected email useEffect(() => { if (!selectedEmailId) return; const index = threadGroups.findIndex(thread => thread.latestEmail.id === selectedEmailId || thread.emails.some(e => e.id === selectedEmailId) ); if (index >= 0) { virtualizer.scrollToIndex(index, { align: 'auto' }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedEmailId]); // Re-measure all items when density or preview settings change useEffect(() => { virtualizer.measure(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [listDensity, showPreview]); return (
{/* Batch Actions Toolbar */}
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
{/* Advanced Search Filter Chips */} {!isFilterEmpty(searchFilters) && ( { const resetValue = DEFAULT_SEARCH_FILTERS[key]; setSearchFilters({ [key]: resetValue }); if (client) advancedSearch(client); }} onClearAll={() => { clearSearchFilters(); if (client) advancedSearch(client); }} /> )} {/* List Header */}

{isLoading ? t('loading') : threadGroups.length > 0 ? (totalEmails !== undefined && totalEmails > threadGroups.length ? t('conversations_count', { count: threadGroups.length, total: totalEmails }) : hasMoreEmails ? t('conversations_count_plus', { count: threadGroups.length }) : t('conversations_count_simple', { count: threadGroups.length })) : t('no_conversations')}

{/* Email List */}
{/* Loading overlay */} {isLoading && emails.length > 0 && (
{t('loading')}
)} {isLoading && emails.length === 0 ? ( ) : emails.length === 0 && !isLoading ? (

{t('no_emails')}

{t('no_emails_description')}

) : ( <>
{virtualizer.getVirtualItems().map((virtualItem) => { const thread = threadGroups[virtualItem.index]; return (
handleToggleThreadExpansion(thread.threadId)} onEmailSelect={(email) => onEmailSelect?.(email)} onContextMenu={openContextMenu} onOpenConversation={onOpenConversation} />
); })}
{isLoadingMore && hasMoreEmails && (
{t('loading_more')}
)} {!hasMoreEmails && emails.length > 0 && (
{t('no_more_emails')}
)}
)}
{/* Context Menu */} {contextMenu.data && ( m.id === selectedMailbox)?.role} isMultiSelect={selectedEmailIds.has(contextMenu.data.id)} selectedCount={selectedEmailIds.size} onReply={() => onReply?.(contextMenu.data!)} onReplyAll={() => onReplyAll?.(contextMenu.data!)} onForward={() => onForward?.(contextMenu.data!)} onMarkAsRead={(read) => onMarkAsRead?.(contextMenu.data!, read)} onToggleStar={() => onToggleStar?.(contextMenu.data!)} onDelete={() => onDelete?.(contextMenu.data!)} onArchive={() => onArchive?.(contextMenu.data!)} onSetColorTag={(color) => onSetColorTag?.(contextMenu.data!.id, color)} onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenu.data!.id, mailboxId)} onMarkAsSpam={() => onMarkAsSpam?.(contextMenu.data!)} onUndoSpam={() => onUndoSpam?.(contextMenu.data!)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchMoveToMailbox={(mailboxId) => client && batchMoveToMailbox(client, mailboxId)} onBatchMarkAsSpam={async () => { if (client) { const emailIds = Array.from(selectedEmailIds); try { await batchMarkAsSpam(client, emailIds); const { toast } = await import('sonner'); toast.success( t('../email_viewer.spam.toast_batch', { count: emailIds.length }) ); } catch { const { toast } = await import('sonner'); toast.error(t('../email_viewer.spam.error')); } } }} onBatchUndoSpam={async () => { if (client) { const emailIds = Array.from(selectedEmailIds); try { await batchUndoSpam(client, emailIds); const { toast } = await import('sonner'); toast.success( t('../email_viewer.spam.toast_not_spam_batch', { count: emailIds.length }) ); } catch { const { toast } = await import('sonner'); toast.error(t('../email_viewer.spam.error_not_spam')); } } }} /> )}
); }