Files
SRCmail/components/email/email-list.tsx
T

647 lines
26 KiB
TypeScript

"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, CalendarClock, ShieldCheck } 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 { useUIStore } from "@/stores/ui-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;
onEmailDoubleClick?: (email: Email) => void;
className?: string;
isLoading?: boolean;
hasMore?: boolean;
isLoadingMoreItems?: 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;
onTogglePinned?: (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;
isScheduledView?: boolean;
onLoadMoreScheduled?: () => void;
onCancelScheduled?: (email: Email) => void | Promise<void>;
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
}
export function EmailList({
emails,
selectedEmailId,
onEmailSelect,
onEmailDoubleClick,
className,
isLoading = false,
hasMore,
isLoadingMoreItems,
onOpenConversation,
onReply,
onReplyAll,
onForward,
onMarkAsRead,
onToggleStar,
onTogglePinned,
onDelete,
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
onEditDraft,
isScheduledView = false,
onLoadMoreScheduled,
onCancelScheduled,
onCancelScheduledForEdit,
onRescheduleScheduled,
}: 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,
markThreadAsRead,
collapseAllThreads,
threadEmailCounts,
searchFilters,
setSearchFilters,
clearSearchFilters,
advancedSearch,
searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore();
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
// there is no concrete mailbox to read the role from. Fall back to the unified
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
// that role's folder.
const effectiveMailboxRole =
mailboxes.find(m => m.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const [isProcessing, setIsProcessing] = useState(false);
const parentRef = useRef<HTMLDivElement>(null);
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
const footerHasMore = hasMore ?? hasMoreEmails;
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
const isMobile = useUIStore((state) => state.isMobile);
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[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 = () => (
<div className="animate-in fade-in duration-200">
{[...Array(8)].map((_, i) => (
<div key={i} className="border-b border-border px-4 py-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 bg-muted/50 rounded-full" />
<div className="flex-1">
<div className="flex items-center justify-between mb-2">
<div className="h-4 bg-muted/50 rounded w-32" />
<div className="h-3 bg-muted/50 rounded w-16" />
</div>
<div className="h-4 bg-muted/50 rounded w-3/4 mb-2" />
<div className="h-3 bg-muted/50 rounded w-full" />
</div>
</div>
</div>
))}
</div>
);
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 handleBatchUndoSpam = async () => {
if (!client || isProcessing) return;
setIsProcessing(true);
try {
const emailIds = Array.from(selectedEmailIds);
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'));
} 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 (isScheduledView) {
onLoadMoreScheduled?.();
return;
}
if (client && hasMoreEmails && !isLoadingMore && !isLoading) {
loadMoreEmails(client);
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
const handleToggleThreadExpansion = useCallback(async (threadId: string) => {
const isExpanded = expandedThreadIds.has(threadId);
if (!isExpanded && client) {
toggleThreadExpansion(threadId);
await fetchThreadEmails(client, threadId);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadId);
} else {
toggleThreadExpansion(threadId);
}
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
// 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<ReturnType<typeof setTimeout> | 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 (
<div className={cn("flex flex-col min-h-0", className)}>
{/* Batch Actions Toolbar */}
<div
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
{t('batch_actions.selected_messages', { count: selectedEmailIds.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(true)}
title={t('batch_actions.mark_read')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<MailOpen className="w-4 h-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleBatchMarkAsRead(false)}
title={t('batch_actions.mark_unread')}
disabled={isProcessing}
className="hover:bg-accent transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Mail className="w-4 h-4" />
)}
</Button>
{effectiveMailboxRole === 'junk' && (
<Button
variant="ghost"
size="sm"
onClick={handleBatchUndoSpam}
title={t('../context_menu.not_spam')}
disabled={isProcessing}
className="text-emerald-600 dark:text-emerald-400 hover:bg-emerald-100/50 dark:hover:bg-emerald-950/30 transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={handleBatchDelete}
title={t('batch_actions.delete')}
disabled={isProcessing}
className="text-red-600 dark:text-red-400 hover:bg-red-100/50 dark:hover:bg-red-950/30 transition-colors disabled:opacity-50"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
<div className="w-px h-6 bg-border mx-1" />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{t('batch_actions.clear_selection')}
</Button>
</div>
</div>
</div>
{/* Advanced Search Filter Chips */}
{!isFilterEmpty(searchFilters) && (
<SearchChips
filters={searchFilters}
onRemoveFilter={(key) => {
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 && (
<div className="px-4 py-2 border-b border-border bg-muted/30 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle className="w-4 h-4" />
<span>{currentMailbox?.role === 'junk' ? t('empty_folder.junk_hint') : t('empty_folder.trash_hint')}</span>
</div>
<Button
variant="outline"
size="sm"
onClick={handleEmptyFolder}
disabled={isProcessing}
className="text-destructive border-destructive/30 hover:bg-destructive/10 text-xs"
>
{isProcessing ? (
<Loader2 className="w-3 h-3 animate-spin me-1" />
) : (
<Trash2 className="w-3 h-3 me-1" />
)}
{t('empty_folder.button')}
</Button>
</div>
)}
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
{isLoading && emails.length > 0 && (
<div className="absolute inset-0 bg-background/50 z-10 flex items-center justify-center animate-in fade-in duration-150">
<div className="flex items-center gap-2 text-sm text-muted-foreground bg-background/90 px-4 py-2 rounded-full shadow-sm border border-border">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading')}</span>
</div>
</div>
)}
{isLoading && emails.length === 0 ? (
<LoadingSkeleton />
) : emails.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center h-full py-12">
<div className="w-20 h-20 mx-auto mb-6 rounded-full bg-muted shadow-lg flex items-center justify-center">
{isScheduledView ? (
<CalendarClock className="w-10 h-10 text-muted-foreground" />
) : searchQuery || !isFilterEmpty(searchFilters) ? (
<SearchX className="w-10 h-10 text-muted-foreground" />
) : (
<MailX className="w-10 h-10 text-muted-foreground" />
)}
</div>
<p className="text-base font-medium text-foreground">
{isScheduledView ? t('no_scheduled_emails') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results') : t('no_emails')}
</p>
<p className="text-sm mt-1 text-muted-foreground">
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
</div>
) : (
<>
<div
className={cn("transition-opacity duration-200", isLoading && "opacity-50")}
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const thread = threadGroups[virtualItem.index];
return (
<div
key={virtualItem.key}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<ThreadListItem
thread={thread}
isExpanded={expandedThreadIds.has(thread.threadId)}
selectedEmailId={selectedEmailId}
isLoading={isLoadingThread === thread.threadId}
expandedEmails={threadEmailsCache.get(thread.threadId)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadId)}
onCollapseAllThreads={collapseAllThreads}
onEmailSelect={(email) => {
// Collapse expanded threads when selecting an email outside the expanded thread
const currentExpanded = useEmailStore.getState().expandedThreadIds;
if (currentExpanded.size > 0) {
// Check if the selected email belongs to any expanded thread via its threadId
if (!email.threadId || !currentExpanded.has(email.threadId)) {
collapseAllThreads();
}
}
onEmailSelect?.(email);
}}
onEmailDoubleClick={onEmailDoubleClick ? (email) => onEmailDoubleClick(email) : undefined}
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}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
</div>
);
})}
</div>
<div className="py-4 flex justify-center">
{footerIsLoadingMore && footerHasMore && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="w-4 h-4 animate-spin" />
<span>{t('loading_more')}</span>
</div>
)}
{!footerHasMore && emails.length > 0 && (
<div className="text-sm text-muted-foreground border-t border-border pt-6">
{t('no_more_emails')}
</div>
)}
</div>
</>
)}
</div>
{/* Context Menu */}
{contextMenu.data && (
<EmailContextMenu
email={contextMenu.data}
position={contextMenu.position}
isOpen={contextMenu.isOpen}
onClose={closeContextMenu}
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={effectiveMailboxRole}
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!)}
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenu.data!) : undefined}
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!)}
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenu.data!) : undefined}
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenu.data!) : undefined}
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenu.data!) : undefined}
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'));
}
}
}}
/>
)}
<ConfirmDialog {...confirmDialogProps} />
</div>
);
}