Adds the same "Forward as attachment" action from the message viewer's
overflow menu to the right-click context menu on a message row in the
list, right after "Forward". Reuses the handleForwardAsAttachment
handler and buildForwardAsAttachmentPayload helper introduced earlier
in this PR - no new logic, just threading the prop down EmailList ->
EmailContextMenu the same way onForward already is.
blobId is already present on list-row emails (EMAIL_LIST_PROPERTIES
includes it specifically for the existing drag-out-to-filesystem .eml
export feature), so this works without any additional fetch. The menu
item is disabled if it's ever missing, matching how other actions
degrade when their handler prop isn't supplied.
Reuses the email_viewer.forward_as_attachment translation key already
added (via a second scoped useTranslations("email_viewer") call,
matching this file's existing cross-namespace pattern for
email_viewer.color_tag) rather than adding a duplicate key under
context_menu - avoids touching all 24 locale files again.
650 lines
26 KiB
TypeScript
650 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;
|
|
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;
|
|
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,
|
|
onForwardAsAttachment,
|
|
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!)}
|
|
onForwardAsAttachment={() => onForwardAsAttachment?.(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>
|
|
);
|
|
}
|