"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 { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; 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 { useConfirmDialog } from "@/hooks/use-confirm-dialog"; 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; onEditDraft?: (email: Email) => void; } export function EmailList({ emails, selectedEmailId, onEmailSelect, className, isLoading = false, onOpenConversation, onReply, onReplyAll, onForward, onMarkAsRead, onToggleStar, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam, onMoveToMailbox, onEditDraft, }: EmailListProps) { const t = useTranslations('email_list'); const { client } = useAuthStore(); const { selectedEmailIds, selectAllEmails: _selectAllEmails, clearSelection, batchMarkAsRead, batchDelete, batchMoveToMailbox, batchArchive, batchMarkAsSpam, batchUndoSpam, loadMoreEmails, hasMoreEmails, isLoadingMore, mailboxes, selectedMailbox, emptyMailbox, expandedThreadIds, threadEmailsCache, isLoadingThread, toggleThreadExpansion, fetchThreadEmails, searchFilters, setSearchFilters, clearSearchFilters, advancedSearch, searchQuery, } = useEmailStore(); const disableThreading = useSettingsStore((state) => state.disableThreading); const threadGroups = useMemo(() => { const groups = groupEmailsByThread(emails, disableThreading); return sortThreadGroups(groups); }, [emails, disableThreading]); const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu(); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const [isProcessing, setIsProcessing] = useState(false); const parentRef = useRef(null); const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const mailLayout = useSettingsStore((state) => state.mailLayout); const isFocusedMailLayout = mailLayout === 'focus'; const estimateSize = useCallback(() => { if (isFocusedMailLayout) { return { 'extra-compact': 32, compact: 40, regular: 46, comfortable: 54 }[density]; } const base = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; return (showPreview && density !== 'extra-compact') ? base + 36 : base; }, [density, isFocusedMailLayout, 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 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) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const confirmed = await confirmDialog({ title: isInTrash ? t('permanent_delete_confirm_title') : t('batch_actions.delete_confirm_title'), message: isInTrash ? t('permanent_delete_confirm_batch_message', { count: selectedEmailIds.size }) : t('batch_actions.delete_confirm_message', { count: selectedEmailIds.size }), confirmText: isInTrash ? t('permanent_delete') : t('batch_actions.delete'), variant: "destructive", }); if (!confirmed) return; setIsProcessing(true); try { await batchDelete(client, isInTrash); const storeError = useEmailStore.getState().error; if (storeError) { const { toast } = await import('sonner'); toast.error(storeError); } } catch (err) { const { toast } = await import('sonner'); toast.error(err instanceof Error ? err.message : 'Failed to delete emails'); } finally { setTimeout(() => setIsProcessing(false), 500); } }; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk'; const handleEmptyFolder = async () => { if (!client || isProcessing || !currentMailbox) return; const confirmed = await confirmDialog({ title: t('empty_folder.confirm_title'), message: t('empty_folder.confirm_message'), confirmText: t('empty_folder.confirm_button'), variant: "destructive", }); if (!confirmed) return; setIsProcessing(true); try { await emptyMailbox(client, currentMailbox.id); } 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. // Debounce to prevent rapid cascade when thread grouping reduces item // count below the viewport size (e.g. 2400 emails → fewer thread groups). const virtualItems = virtualizer.getVirtualItems(); const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; const loadMoreTimerRef = useRef | null>(null); useEffect(() => { if (lastVirtualItemIndex === undefined) return; if (lastVirtualItemIndex >= threadGroups.length - 5) { // Clear any pending timer so we don't stack calls if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); loadMoreTimerRef.current = setTimeout(() => { handleLoadMore(); loadMoreTimerRef.current = null; }, 150); } return () => { if (loadMoreTimerRef.current) clearTimeout(loadMoreTimerRef.current); }; }, [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 }, [density, isFocusedMailLayout, 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); }} /> )} {/* Empty Folder Banner for Junk/Trash */} {isEmptyableFolder && emails.length > 0 && !hasSelection && (
{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}
)} {/* Email List */}
{/* Loading overlay */} {isLoading && emails.length > 0 && (
{t('loading')}
)} {isLoading && emails.length === 0 ? ( ) : emails.length === 0 && !isLoading ? (
{searchQuery || !isFilterEmpty(searchFilters) ? ( ) : ( )}

{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}

{searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : 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} onToggleStar={onToggleStar ? (email) => onToggleStar(email) : undefined} onMarkAsRead={onMarkAsRead ? (email, read) => onMarkAsRead(email, read) : undefined} onDelete={onDelete ? (email) => onDelete(email) : undefined} onArchive={onArchive ? (email) => onArchive(email) : undefined} onSetColorTag={onSetColorTag} onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined} />
); })}
{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!)} onEditDraft={() => onEditDraft?.(contextMenu.data!)} onBatchMarkAsRead={(read) => client && batchMarkAsRead(client, read)} onBatchDelete={() => client && batchDelete(client)} onBatchArchive={async () => { if (!client) return; try { await batchArchive(client); } catch (error) { console.error('Failed to batch archive:', error); } }} 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')); } } }} /> )}
); }