- P2.2: 'Create Appointment' button in email viewer → pre-fills event modal with subject, body, participants, date. calendar-store newEventPrefill state. - P2.4: MiniCalendarDashlet in sidebar bottom — month grid with event dots, day click navigates to calendar. Collapsible, respect firstDayOfWeek. - P2.12: Custom radial menu (components/ui/radial-menu.tsx) — circular SVG menu with keyboard nav, animations. Wired into email-list, contact-list, file-browser, calendar-month-view right-click handlers. - P2.14: 'Send as Attachment' button in file browser — opens compose tab with selected files pre-attached via Pro tab store.
768 lines
30 KiB
TypeScript
768 lines
30 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 { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
|
import { Reply, ReplyAll, Forward, Star, Archive, FolderOpen } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
|
import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-display";
|
|
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;
|
|
onForwardAsAttachment?: (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;
|
|
onSetTag?: (emailId: string, tagId: 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,
|
|
onForwardAsAttachment,
|
|
onMarkAsRead,
|
|
onToggleStar,
|
|
onTogglePinned,
|
|
onDelete,
|
|
onArchive,
|
|
onSetTag,
|
|
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>();
|
|
/**
|
|
* The row the menu was opened on, as the list currently has it. The menu holds
|
|
* the message it was handed when it opened, but tags can be applied from
|
|
* inside it without dismissing it, so what it draws has to keep up.
|
|
*/
|
|
const contextMenuEmail = contextMenu.data
|
|
? emails.find((email) => email.id === contextMenu.data!.id) ?? contextMenu.data
|
|
: null;
|
|
|
|
// Radial menu state
|
|
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
|
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
|
const [radialMenuEmail, setRadialMenuEmail] = useState<Email | null>(null);
|
|
|
|
const openRadialMenu = useCallback((e: React.MouseEvent, email: Email) => {
|
|
e.preventDefault();
|
|
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
|
setRadialMenuEmail(email);
|
|
setRadialMenuOpen(true);
|
|
}, []);
|
|
|
|
const closeRadialMenu = useCallback(() => {
|
|
setRadialMenuOpen(false);
|
|
}, []);
|
|
|
|
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
|
if (!radialMenuEmail) return [];
|
|
const email = radialMenuEmail;
|
|
const isUnread = !email.keywords?.$seen;
|
|
const isStarred = email.keywords?.$flagged;
|
|
|
|
const act = (fn?: (email: Email) => void) => fn ? () => { fn(email); } : undefined;
|
|
|
|
const items: RadialMenuItem[] = [];
|
|
|
|
if (onReply) {
|
|
items.push({
|
|
id: "reply",
|
|
icon: <Reply className="w-5 h-5" />,
|
|
label: t("../context_menu.reply"),
|
|
onClick: () => { act(onReply)!(); },
|
|
});
|
|
}
|
|
if (onReplyAll) {
|
|
items.push({
|
|
id: "reply-all",
|
|
icon: <ReplyAll className="w-5 h-5" />,
|
|
label: t("../context_menu.reply_all"),
|
|
onClick: () => { act(onReplyAll)!(); },
|
|
});
|
|
}
|
|
if (onForward) {
|
|
items.push({
|
|
id: "forward",
|
|
icon: <Forward className="w-5 h-5" />,
|
|
label: t("../context_menu.forward"),
|
|
onClick: () => { act(onForward)!(); },
|
|
});
|
|
}
|
|
if (onToggleStar) {
|
|
items.push({
|
|
id: "star",
|
|
icon: <Star className="w-5 h-5" fill={isStarred ? "currentColor" : "none"} />,
|
|
label: isStarred ? t("../context_menu.unstar") : t("../context_menu.star"),
|
|
onClick: () => { act(onToggleStar)!(); },
|
|
});
|
|
}
|
|
if (onMarkAsRead) {
|
|
items.push({
|
|
id: "mark-read",
|
|
icon: isUnread ? <MailOpen className="w-5 h-5" /> : <Mail className="w-5 h-5" />,
|
|
label: isUnread ? t("../context_menu.mark_read") : t("../context_menu.mark_unread"),
|
|
onClick: () => { onMarkAsRead(email, !isUnread); },
|
|
});
|
|
}
|
|
if (onArchive) {
|
|
items.push({
|
|
id: "archive",
|
|
icon: <Archive className="w-5 h-5" />,
|
|
label: t("../context_menu.archive"),
|
|
onClick: () => { act(onArchive)!(); },
|
|
});
|
|
}
|
|
if (onDelete) {
|
|
items.push({
|
|
id: "delete",
|
|
icon: <Trash2 className="w-5 h-5" />,
|
|
label: t("../context_menu.delete"),
|
|
onClick: () => { act(onDelete)!(); },
|
|
destructive: true,
|
|
});
|
|
}
|
|
if (onMoveToMailbox) {
|
|
items.push({
|
|
id: "move",
|
|
icon: <FolderOpen className="w-5 h-5" />,
|
|
label: t("../context_menu.move_to"),
|
|
onClick: () => { openContextMenu({ preventDefault: () => {}, stopPropagation: () => {}, clientX: radialMenuPos.x, clientY: radialMenuPos.y } as React.MouseEvent, email); },
|
|
});
|
|
}
|
|
|
|
return items;
|
|
}, [radialMenuEmail, radialMenuPos, t, onReply, onReplyAll, onForward, onToggleStar, onMarkAsRead, onArchive, onDelete, onMoveToMailbox, openContextMenu]);
|
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
// One tag treatment for the whole list, measured from the scroll container.
|
|
const tagDisplay = useMeasuredTagDisplay(parentRef);
|
|
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 (
|
|
<TagDisplayContext.Provider value={tagDisplay}>
|
|
<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={(e, email) => { openContextMenu(e, email); openRadialMenu(e, email); }}
|
|
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}
|
|
onSetTag={onSetTag}
|
|
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>
|
|
|
|
{/* Radial Action Menu */}
|
|
<RadialMenu
|
|
items={radialMenuItems}
|
|
isOpen={radialMenuOpen}
|
|
position={radialMenuPos}
|
|
onClose={closeRadialMenu}
|
|
/>
|
|
|
|
{/* Context Menu */}
|
|
{contextMenuEmail && (
|
|
<EmailContextMenu
|
|
email={contextMenuEmail}
|
|
position={contextMenu.position}
|
|
isOpen={contextMenu.isOpen}
|
|
onClose={closeContextMenu}
|
|
menuRef={menuRef}
|
|
mailboxes={mailboxes}
|
|
selectedMailbox={selectedMailbox}
|
|
currentMailboxRole={effectiveMailboxRole}
|
|
isMultiSelect={selectedEmailIds.has(contextMenuEmail.id)}
|
|
selectedCount={selectedEmailIds.size}
|
|
onReply={() => onReply?.(contextMenuEmail!)}
|
|
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
|
onForward={() => onForward?.(contextMenuEmail!)}
|
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
|
onMarkAsRead={(read) => onMarkAsRead?.(contextMenuEmail!, read)}
|
|
onToggleStar={() => onToggleStar?.(contextMenuEmail!)}
|
|
onTogglePinned={onTogglePinned ? () => onTogglePinned(contextMenuEmail!) : undefined}
|
|
onDelete={() => onDelete?.(contextMenuEmail!)}
|
|
onArchive={() => onArchive?.(contextMenuEmail!)}
|
|
onSetTag={(color) => onSetTag?.(contextMenuEmail!.id, color)}
|
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId)}
|
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
|
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
|
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
|
onCancelScheduled={onCancelScheduled ? () => onCancelScheduled(contextMenuEmail!) : undefined}
|
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : 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>
|
|
</TagDisplayContext.Provider>
|
|
);
|
|
}
|