mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
1235 lines
55 KiB
TypeScript
1235 lines
55 KiB
TypeScript
"use client";
|
|
|
|
import { Email, ThreadGroup } from "@/lib/jmap/types";
|
|
import { MemoizedThreadListItem, listMailboxRole } from "./thread-list-item";
|
|
import { ListColumnsHeader } from "./list-columns";
|
|
import { useStableHandlers } from "@/hooks/use-stable-handlers";
|
|
import type { Attachment } from "@/lib/jmap/types";
|
|
import type { LoadListAttachments } from "@/lib/list-attachments";
|
|
import { listRowShowsChips } from "./attachment-chips";
|
|
import { listVerificationCode } from "@/lib/verification-code";
|
|
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
|
|
import { BatchTagButton } from "./batch-tag-button";
|
|
import { cn, cleanPreview } from "@/lib/utils";
|
|
import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X, FolderX } from "@/components/icons";
|
|
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
|
import { useEmailStore, ArchiveMailboxNotFoundError, resolveSelectedEmails, bulkDeleteIsPermanent, shouldConfirmBulkDelete, removalFolder, canRemoveFromFolder, type ListRow } from "@/stores/email-store";
|
|
import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useAuthStore } from "@/stores/auth-store";
|
|
import { useAccountStore } from "@/stores/account-store";
|
|
import { useSettingsStore } from "@/stores/settings-store";
|
|
import { useListRowShape } from "@/hooks/use-list-row-shape";
|
|
import { useUIStore } from "@/stores/ui-store";
|
|
import { useMessageListTabsStore } from "@/stores/message-list-tabs-store";
|
|
import { emailKeyFor, groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils";
|
|
import { useContextMenu } from "@/hooks/use-context-menu";
|
|
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
|
import { useTranslations } from "next-intl";
|
|
import { toast } from "@/stores/toast-store";
|
|
import { runBatchEmailAction } from "@/lib/email-action-toast";
|
|
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";
|
|
import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
|
|
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
|
|
import { normalizeAddress } from "@/lib/filters/quick-rules";
|
|
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
|
import { PluginActions } from "@/components/plugins/plugin-actions";
|
|
import { useUnreadFirstSection } from "@/hooks/use-unread-first-section";
|
|
import { unreadSectionRowCount } from "@/lib/unread-first";
|
|
import { MvListSectionHeader } from "./mv-list-section-header";
|
|
import { type OpenableEmail, PluginSearchResults } from "./plugin-search-results";
|
|
|
|
interface EmailListProps {
|
|
emails: Email[];
|
|
/** emailKeyFor of the open message: ids repeat across accounts. */
|
|
selectedEmailKey?: string;
|
|
onEmailSelect?: (email: Email) => void;
|
|
/** Opens a message a plugin's search result names (PluginSearchResults). */
|
|
onOpenSearchResult?: (email: OpenableEmail) => void;
|
|
/** A mouse was pressed on a row: the click that follows will open it. */
|
|
onEmailPrefetch?: (email: Email) => void;
|
|
onEmailDoubleClick?: (email: Email) => void;
|
|
/** Mountain View: search for the mail a sender has sent, from the row menu. */
|
|
onFindFromSender?: (sender: { name: string; email: string }) => 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;
|
|
onBatchForwardAsAttachment?: () => void;
|
|
onMarkAsRead?: (email: Email, read: boolean) => void;
|
|
onToggleStar?: (email: Email) => void;
|
|
onTogglePinned?: (email: Email) => void;
|
|
onDelete?: (email: Email) => void;
|
|
onArchive?: (email: Email) => void;
|
|
/** `emailKey` (emailKeyFor of the row) tells namesakes in other accounts apart. */
|
|
onSetTag?: (emailId: string, tagId: string | null, emailKey?: string) => void;
|
|
onMoveToMailbox?: (emailId: string, mailboxId: string, emailKey?: string) => void;
|
|
onMarkAsSpam?: (email: Email) => void;
|
|
onUndoSpam?: (email: Email) => void;
|
|
onOpenAttachment?: (email: Email, attachment: Attachment) => void;
|
|
loadAttachments?: LoadListAttachments;
|
|
onEditDraft?: (email: Email) => void;
|
|
isScheduledView?: boolean;
|
|
onLoadMoreScheduled?: () => void;
|
|
onCancelScheduledForEdit?: (email: Email) => void | Promise<void>;
|
|
onRescheduleScheduled?: (email: Email) => void | Promise<void>;
|
|
/**
|
|
* Mountain View folds the batch verbs into the single toolbar above the
|
|
* list, so the separate bar that would otherwise slide in here is redundant.
|
|
*/
|
|
hideBatchToolbar?: boolean;
|
|
}
|
|
|
|
/**
|
|
* What the virtual list draws: a row, or in the "Unread first" inbox a
|
|
* section's header and the line an empty Unread section shows.
|
|
*/
|
|
type ListItem =
|
|
| { kind: 'row'; thread: ThreadGroup }
|
|
| { kind: 'header'; section: 'unread' | 'rest' }
|
|
| { kind: 'unread-empty' };
|
|
|
|
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
|
|
const BATCH_TOOLBAR_WIDE = 480;
|
|
|
|
// Declared out here so re-renders of the list keep the same element. Inside
|
|
// EmailList every render made a new component, which remounted the skeleton
|
|
// and replayed its fade-in each time the store changed while a list loaded.
|
|
function LoadingSkeleton() {
|
|
return (
|
|
<div className="animate-in fade-in duration-200" data-testid="email-list-skeleton">
|
|
{[...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>
|
|
);
|
|
}
|
|
|
|
export function EmailList({
|
|
emails,
|
|
selectedEmailKey,
|
|
onEmailSelect,
|
|
onOpenSearchResult,
|
|
onEmailPrefetch,
|
|
onEmailDoubleClick,
|
|
onFindFromSender,
|
|
className,
|
|
isLoading = false,
|
|
hasMore,
|
|
isLoadingMoreItems,
|
|
onOpenConversation,
|
|
onReply,
|
|
onReplyAll,
|
|
onForward,
|
|
onForwardAsAttachment,
|
|
onBatchForwardAsAttachment,
|
|
onMarkAsRead,
|
|
onToggleStar,
|
|
onTogglePinned,
|
|
onDelete,
|
|
onArchive,
|
|
onSetTag,
|
|
onMarkAsSpam,
|
|
onUndoSpam,
|
|
onOpenAttachment,
|
|
loadAttachments,
|
|
onMoveToMailbox,
|
|
onEditDraft,
|
|
isScheduledView = false,
|
|
hideBatchToolbar = false,
|
|
onLoadMoreScheduled,
|
|
onCancelScheduledForEdit,
|
|
onRescheduleScheduled,
|
|
}: EmailListProps) {
|
|
const t = useTranslations('email_list');
|
|
const tContextMenu = useTranslations('context_menu');
|
|
const tSpam = useTranslations('email_viewer.spam');
|
|
const tNotifications = useTranslations('notifications');
|
|
const tViewer = useTranslations('email_viewer');
|
|
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
|
|
const {
|
|
selectedEmailKeys,
|
|
selectAllEmails: _selectAllEmails,
|
|
clearSelection,
|
|
batchMarkAsRead,
|
|
batchSetTag,
|
|
batchDelete,
|
|
batchMoveToMailbox,
|
|
batchArchive,
|
|
batchMarkAsSpam,
|
|
batchUndoSpam,
|
|
loadMoreEmails,
|
|
hasMoreEmails,
|
|
isLoadingMore,
|
|
mailboxes,
|
|
selectedMailbox,
|
|
emptyMailbox,
|
|
expandedThreadIds,
|
|
threadEmailsCache,
|
|
isLoadingThread,
|
|
toggleThreadExpansion,
|
|
fetchThreadEmails,
|
|
autoMarkExpandedThreadAsRead,
|
|
collapseAllThreads,
|
|
threadEmailCounts,
|
|
searchFilters,
|
|
setSearchFilters,
|
|
clearSearchFilters,
|
|
advancedSearch,
|
|
searchQuery,
|
|
isUnifiedView,
|
|
unifiedRole,
|
|
} = useEmailStore(useShallow((state) => ({
|
|
selectedEmailKeys: state.selectedEmailKeys,
|
|
selectAllEmails: state.selectAllEmails,
|
|
clearSelection: state.clearSelection,
|
|
batchMarkAsRead: state.batchMarkAsRead,
|
|
batchSetTag: state.batchSetTag,
|
|
batchDelete: state.batchDelete,
|
|
batchMoveToMailbox: state.batchMoveToMailbox,
|
|
batchArchive: state.batchArchive,
|
|
batchMarkAsSpam: state.batchMarkAsSpam,
|
|
batchUndoSpam: state.batchUndoSpam,
|
|
loadMoreEmails: state.loadMoreEmails,
|
|
hasMoreEmails: state.hasMoreEmails,
|
|
isLoadingMore: state.isLoadingMore,
|
|
mailboxes: state.mailboxes,
|
|
selectedMailbox: state.selectedMailbox,
|
|
emptyMailbox: state.emptyMailbox,
|
|
expandedThreadIds: state.expandedThreadIds,
|
|
threadEmailsCache: state.threadEmailsCache,
|
|
isLoadingThread: state.isLoadingThread,
|
|
toggleThreadExpansion: state.toggleThreadExpansion,
|
|
fetchThreadEmails: state.fetchThreadEmails,
|
|
autoMarkExpandedThreadAsRead: state.autoMarkExpandedThreadAsRead,
|
|
collapseAllThreads: state.collapseAllThreads,
|
|
threadEmailCounts: state.threadEmailCounts,
|
|
searchFilters: state.searchFilters,
|
|
setSearchFilters: state.setSearchFilters,
|
|
clearSearchFilters: state.clearSearchFilters,
|
|
advancedSearch: state.advancedSearch,
|
|
searchQuery: state.searchQuery,
|
|
isUnifiedView: state.isUnifiedView,
|
|
unifiedRole: state.unifiedRole,
|
|
})));
|
|
|
|
// 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);
|
|
// The order the current folder view was fetched in (#718), so thread
|
|
// grouping mirrors the server order instead of re-sorting the page by date.
|
|
// Search results and cross-account views are always chronological.
|
|
const fetchedListOrder = useEmailStore((state) => state.listOrder);
|
|
const keepServerTextOrder = useEmailStore((state) => state.listKeepsServerTextOrder);
|
|
const crossView = useEmailStore((state) => state.crossView);
|
|
// The row opened last stays where it was clicked while that order would
|
|
// move it (e.g. read in "unread first").
|
|
const listHold = useEmailStore((state) => state.listHold);
|
|
const viewingAccountId = useEmailStore((state) => state.viewingAccountId);
|
|
const selectedKeyword = useEmailStore((state) => state.selectedKeyword);
|
|
const searchMailboxId = useEmailStore((state) => state.searchMailboxId);
|
|
const activeAccountId = useAuthStore((state) => state.activeAccountId);
|
|
const activeTabId = useMessageListTabsStore((state) => state.activeTabId);
|
|
|
|
const allThreadGroups = useMemo(() => {
|
|
const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder;
|
|
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
|
|
return sortThreadGroups(groups, listOrder, { keepServerTextOrder, held: listHold?.keywords });
|
|
}, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, keepServerTextOrder, searchQuery, crossView, searchFilters, listHold]);
|
|
|
|
// Mountain View's "Unread first" inbox: the Unread section's conversations
|
|
// go first, under a header each section has, and a folded section's rows
|
|
// leave the list. A conversation with a message in the section is the
|
|
// section's.
|
|
const unreadSection = useUnreadFirstSection();
|
|
const unreadCollapsed = useSettingsStore((state) => state.mvUnreadSectionCollapsed);
|
|
const restCollapsed = useSettingsStore((state) => state.mvEverythingElseCollapsed);
|
|
const updateSetting = useSettingsStore((state) => state.updateSetting);
|
|
const sections = useMemo(() => {
|
|
if (!unreadSection) return null;
|
|
const unread: ThreadGroup[] = [];
|
|
const rest: ThreadGroup[] = [];
|
|
for (const group of allThreadGroups) {
|
|
(group.emails.some((email) => unreadSection.ids.has(email.id)) ? unread : rest).push(group);
|
|
}
|
|
return { unread, rest };
|
|
}, [allThreadGroups, unreadSection]);
|
|
const threadGroups = useMemo(() => (
|
|
sections
|
|
? [...(unreadCollapsed ? [] : sections.unread), ...(restCollapsed ? [] : sections.rest)]
|
|
: allThreadGroups
|
|
), [sections, unreadCollapsed, restCollapsed, allThreadGroups]);
|
|
const { items, itemOfThread } = useMemo(() => {
|
|
const items: ListItem[] = [];
|
|
const itemOfThread: number[] = [];
|
|
const addRows = (groups: ThreadGroup[]) => {
|
|
for (const thread of groups) {
|
|
itemOfThread.push(items.length);
|
|
items.push({ kind: 'row', thread });
|
|
}
|
|
};
|
|
if (!sections) {
|
|
addRows(threadGroups);
|
|
return { items, itemOfThread };
|
|
}
|
|
items.push({ kind: 'header', section: 'unread' });
|
|
if (!unreadCollapsed) {
|
|
if (sections.unread.length === 0) items.push({ kind: 'unread-empty' });
|
|
addRows(sections.unread);
|
|
}
|
|
items.push({ kind: 'header', section: 'rest' });
|
|
if (!restCollapsed) addRows(sections.rest);
|
|
return { items, itemOfThread };
|
|
}, [sections, threadGroups, unreadCollapsed, restCollapsed]);
|
|
const sectionRowCount = useMemo(() => unreadSectionRowCount(emails, unreadSection), [emails, unreadSection]);
|
|
const totalEmails = useEmailStore((state) => state.totalEmails);
|
|
|
|
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.
|
|
*/
|
|
// The live copy of the row the menu was opened on, by its key: in a merged
|
|
// view the bare id is also another account's message, and every action of
|
|
// the menu would go to that namesake instead.
|
|
const contextMenuEmail = contextMenu.data
|
|
? emails.find((email) => emailKeyFor(email) === emailKeyFor(contextMenu.data!)) ?? contextMenu.data
|
|
: null;
|
|
/**
|
|
* Whose sender the menu's Rules entry uses: every selected message, or the
|
|
* row's own message. A thread row whose newest message is the user's own
|
|
* reply takes the newest one someone else sent, when the thread has it
|
|
* loaded; otherwise the entry offers no sender rules.
|
|
*/
|
|
const ruleEmails = useMemo(() => {
|
|
if (!contextMenuEmail) return undefined;
|
|
if (selectedEmailKeys.has(emailKeyFor(contextMenuEmail)) && selectedEmailKeys.size > 1) {
|
|
return emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email)));
|
|
}
|
|
const own = getOwnAddresses();
|
|
const isOwn = (email: Email) =>
|
|
(email.from ?? []).length > 0 && (email.from ?? []).every((f) => own.has(normalizeAddress(f.email)));
|
|
if (!isOwn(contextMenuEmail)) return undefined;
|
|
const thread = threadGroups.find((group) => group.latestEmail.id === contextMenuEmail.id);
|
|
if (!thread) return undefined;
|
|
const newestOther = [...(threadEmailsCache.get(thread.threadKey) ?? []), ...thread.emails]
|
|
.filter((email) => !isOwn(email))
|
|
.sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))[0];
|
|
return newestOther ? [newestOther] : undefined;
|
|
}, [contextMenuEmail, selectedEmailKeys, emails, threadGroups, threadEmailsCache]);
|
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
|
|
|
// "Copy to" lists the folders of the other connected accounts. Only with
|
|
// more than one account connected; the menu's own account is left out.
|
|
const accounts = useAccountStore((state) => state.accounts);
|
|
const accountMailboxes = useEmailStore((state) => state.accountMailboxes);
|
|
const fetchAccountMailboxes = useEmailStore((state) => state.fetchAccountMailboxes);
|
|
const copyEmailsToAccount = useEmailStore((state) => state.copyEmailsToAccount);
|
|
const copySourceLogin = contextMenuEmail
|
|
? contextMenuEmail.sourceClientAccountId ?? viewingAccountId ?? activeAccountId
|
|
: null;
|
|
const copyAccounts = useMemo(() => {
|
|
if (!contextMenu.isOpen) return [];
|
|
const connected = useAuthStore.getState().getAllConnectedClients();
|
|
if (connected.size < 2) return [];
|
|
return accounts.filter((account) => account.id !== copySourceLogin && connected.has(account.id));
|
|
}, [contextMenu.isOpen, accounts, copySourceLogin]);
|
|
useEffect(() => {
|
|
for (const account of copyAccounts) {
|
|
if (accountMailboxes[account.id]) continue;
|
|
const accountClient = useAuthStore.getState().getClientForAccount(account.id);
|
|
if (accountClient) void fetchAccountMailboxes(accountClient, account.id);
|
|
}
|
|
}, [copyAccounts, accountMailboxes, fetchAccountMailboxes]);
|
|
const copyTargets = useMemo<CopyTargetAccount[]>(
|
|
() => copyAccounts
|
|
.filter((account) => accountMailboxes[account.id])
|
|
.map((account) => ({ accountId: account.id, label: account.label || account.email || account.id, mailboxes: accountMailboxes[account.id] })),
|
|
[copyAccounts, accountMailboxes],
|
|
);
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
const batchToolbarRef = useRef<HTMLDivElement>(null);
|
|
// The batch toolbar sits above the scroll container, so as it animates
|
|
// open it shrinks the list and would shove every row downwards. Feed each
|
|
// height change back into scrollTop so the rows stay put on screen (and
|
|
// slide back when the toolbar collapses again).
|
|
//
|
|
// Except at the very top: there are no rows above to hold steady, so the
|
|
// compensation just scrolls the first message underneath the toolbar and
|
|
// reads as the toolbar covering the message you selected. Let the list
|
|
// move down there instead.
|
|
useEffect(() => {
|
|
const toolbar = batchToolbarRef.current;
|
|
if (!toolbar || typeof ResizeObserver === 'undefined') return;
|
|
let lastHeight = toolbar.getBoundingClientRect().height;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const height = entries[0]?.contentRect.height ?? toolbar.getBoundingClientRect().height;
|
|
const delta = height - lastHeight;
|
|
lastHeight = height;
|
|
const list = parentRef.current;
|
|
if (!list || delta === 0) return;
|
|
if (delta > 0 && list.scrollTop <= 0) return;
|
|
list.scrollTop = Math.max(0, list.scrollTop + delta);
|
|
});
|
|
observer.observe(toolbar);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
// A narrow list (folder sidebar open, phones) has no room for the batch
|
|
// toolbar's labels: plugin buttons go behind one "more" button and "Clear
|
|
// selection" becomes an icon, instead of wrapping into each other.
|
|
const [batchNarrow, setBatchNarrow] = useState(false);
|
|
useEffect(() => {
|
|
const toolbar = batchToolbarRef.current;
|
|
if (!toolbar || typeof ResizeObserver === 'undefined') return;
|
|
const observer = new ResizeObserver((entries) => {
|
|
const width = entries[0]?.contentRect.width ?? toolbar.getBoundingClientRect().width;
|
|
if (width > 0) setBatchNarrow(width < BATCH_TOOLBAR_WIDE);
|
|
});
|
|
observer.observe(toolbar);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
// 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 showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes);
|
|
const { oneLine: isFocusedMailLayout, columns: listColumns } = useListRowShape();
|
|
const showRecipientColumn = useEmailStore((state) => {
|
|
const role = listMailboxRole(state);
|
|
return role === 'sent' || role === 'drafts';
|
|
});
|
|
const footerHasMore = hasMore ?? hasMoreEmails;
|
|
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
|
const isMobile = useUIStore((state) => state.isMobile);
|
|
// Hand the rows, in screen order, to the store for shift-click ranges: a
|
|
// thread row selects all its messages, and an expanded thread's messages
|
|
// are rows of their own under it. (#1230)
|
|
const setListRows = useEmailStore((state) => state.setListRows);
|
|
useEffect(() => {
|
|
const rows: ListRow[] = [];
|
|
for (const thread of threadGroups) {
|
|
rows.push({ key: emailKeyFor(thread.latestEmail), members: thread.emails.map(emailKeyFor) });
|
|
if (thread.emails.length > 1 && expandedThreadIds.has(thread.threadKey) && !isMobile) {
|
|
for (const email of threadEmailsCache.get(thread.threadKey) ?? thread.emails) {
|
|
rows.push({ key: emailKeyFor(email), members: [emailKeyFor(email)] });
|
|
}
|
|
}
|
|
}
|
|
setListRows(rows);
|
|
}, [threadGroups, expandedThreadIds, threadEmailsCache, isMobile, setListRows]);
|
|
useEffect(() => () => setListRows(null), [setListRows]);
|
|
|
|
// The virtualizer's guesses follow the rows' shape (see useListRowShape).
|
|
|
|
const estimateSize = useCallback((index: number) => {
|
|
const item = items[index];
|
|
if (item?.kind === 'header' || item?.kind === 'unread-empty') return 48;
|
|
if (isFocusedMailLayout) {
|
|
return { 'extra-compact': 28, compact: 40, regular: 56, comfortable: 64 }[density];
|
|
}
|
|
// Rows the list has not measured yet are placed by this guess, and each
|
|
// one it gets wrong shifts the list when it is measured on the way back
|
|
// up after a jump down (scrollbar drag, End). So guess per row.
|
|
const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined;
|
|
let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density];
|
|
if (showPreview && density !== 'extra-compact') {
|
|
// A mail without a preview draws a one-line "No preview available",
|
|
// a line (23px) shorter than a real one.
|
|
const emptyPreview = !!latest && !cleanPreview(latest.preview) && !latest.searchSnippet?.preview;
|
|
size += emptyPreview ? 36 - 23 : 36;
|
|
}
|
|
// The chip row (attachments, verification code): a 22px chip plus 6px margin.
|
|
const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest);
|
|
if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28;
|
|
return size;
|
|
}, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]);
|
|
|
|
// Stable per list, so the virtualizer does not rebuild every row's
|
|
// measurement on each scroll render.
|
|
const getItemKey = useCallback(
|
|
(index: number) => {
|
|
const item = items[index];
|
|
if (item?.kind === 'row') return item.thread.threadKey;
|
|
if (item?.kind === 'header') return `section:${item.section}`;
|
|
return item ? 'section:unread-empty' : String(index);
|
|
},
|
|
[items],
|
|
);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => parentRef.current,
|
|
estimateSize,
|
|
overscan: 5,
|
|
getItemKey,
|
|
// Keep sub-pixel row heights. The default measurer rounds them, so a
|
|
// row could start up to half a pixel inside the one above it and paint
|
|
// over that row's divider (at 125% or 150% display scaling).
|
|
measureElement: (element, entry) =>
|
|
entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height,
|
|
});
|
|
|
|
// Another folder, tag, account, unified view or plugin tab - or another
|
|
// search - opens at the top. The scroll container outlives the switch, so
|
|
// the new list would otherwise open wherever the last one was scrolled to.
|
|
// Refreshes, new mail and loading more keep the view and so the position.
|
|
const searching = !!searchQuery.trim() || !isFilterEmpty(searchFilters);
|
|
const viewKey = JSON.stringify([
|
|
activeAccountId,
|
|
viewingAccountId,
|
|
selectedMailbox,
|
|
selectedKeyword,
|
|
isUnifiedView && (crossView ?? unifiedRole),
|
|
isScheduledView,
|
|
activeTabId,
|
|
// The scope only matters while a search runs; the dropdown alone does not
|
|
// change the list.
|
|
searching && [searchQuery, searchFilters, searchMailboxId],
|
|
]);
|
|
const shownViewKey = useRef(viewKey);
|
|
useLayoutEffect(() => {
|
|
if (shownViewKey.current === viewKey) return;
|
|
shownViewKey.current = viewKey;
|
|
virtualizer.scrollToOffset(0);
|
|
}, [viewKey, virtualizer]);
|
|
|
|
const hasSelection = selectedEmailKeys.size > 0;
|
|
// What plugin buttons in the selection toolbar act on: the JMAP ids of the
|
|
// selected messages, thread members included.
|
|
const selectedEmailIds = useMemo(
|
|
() => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id),
|
|
[emails, threadEmailsCache, selectedEmailKeys],
|
|
);
|
|
const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
|
|
// "Remove from this folder" (#615): offered for a selection only when every
|
|
// selected message is also in another folder.
|
|
const removeFromFolder = useRemoveFromFolder();
|
|
const folderToLeave = useEmailStore(removalFolder);
|
|
const selectedEmailsForRemoval = useMemo(
|
|
() => (hasSelection ? resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }) : []),
|
|
[hasSelection, emails, threadEmailsCache, selectedEmailKeys],
|
|
);
|
|
const selectionCanLeaveFolder = selectedEmailsForRemoval.length > 0
|
|
&& selectedEmailsForRemoval.every((email) => canRemoveFromFolder(email, folderToLeave));
|
|
const handleBatchRemoveFromFolder = async () => {
|
|
if (isProcessing) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
await removeFromFolder(resolveSelectedEmails(useEmailStore.getState()));
|
|
} finally {
|
|
setTimeout(() => setIsProcessing(false), 500);
|
|
}
|
|
};
|
|
const listSlotProps = useMemo(
|
|
() => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }),
|
|
[selectedMailbox, effectiveMailboxRole],
|
|
);
|
|
const isSearching = !!searchQuery || !isFilterEmpty(searchFilters);
|
|
const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]);
|
|
|
|
// The store reports a refused message (a read-only shared folder) through
|
|
// its error; the rest of the selection is marked.
|
|
const markSelectionRead = (read: boolean) => client
|
|
? runBatchEmailAction(() => batchMarkAsRead(client, read), { error: tNotifications('error_updating') })
|
|
: Promise.resolve();
|
|
|
|
const handleBatchMarkAsRead = async (read: boolean) => {
|
|
if (!client || isProcessing) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
await markSelectionRead(read);
|
|
} finally {
|
|
setTimeout(() => setIsProcessing(false), 500);
|
|
}
|
|
};
|
|
|
|
// What the tag picker shows for the selection: a tag on every selected
|
|
// message is checked, one on only some of them is drawn as partial.
|
|
const selectionTags = useMemo(() => {
|
|
const selected = emails.filter((email) => selectedEmailKeys.has(emailKeyFor(email)));
|
|
const counts = new Map<string, number>();
|
|
for (const email of selected) {
|
|
for (const tagId of getEmailTagIds(email.keywords)) counts.set(tagId, (counts.get(tagId) ?? 0) + 1);
|
|
}
|
|
const all: string[] = [];
|
|
const some: string[] = [];
|
|
for (const [tagId, count] of counts) (count === selected.length ? all : some).push(tagId);
|
|
return { all, some };
|
|
}, [emails, selectedEmailKeys]);
|
|
|
|
// A tag every selected message has comes off; any other goes onto all of
|
|
// them. The selection stays, so several tags can be changed in a row.
|
|
const handleBatchToggleTag = async (tagId: string) => {
|
|
if (!client) return;
|
|
try {
|
|
await batchSetTag(client, tagId, !selectionTags.all.includes(tagId));
|
|
} catch (error) {
|
|
console.error("Failed to tag emails:", error);
|
|
toast.error(tNotifications('error_updating'));
|
|
}
|
|
};
|
|
|
|
const handleBatchUndoSpam = async () => {
|
|
if (!client || isProcessing) return;
|
|
setIsProcessing(true);
|
|
try {
|
|
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
|
if (await batchUndoSpam(client, emailIds)) toast.success(tSpam('toast_not_spam_batch', { count: emailIds.length }));
|
|
} catch {
|
|
toast.error(tSpam('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 = bulkDeleteIsPermanent(currentMailbox);
|
|
|
|
if (shouldConfirmBulkDelete(currentMailbox)) {
|
|
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: selectedEmailKeys.size })
|
|
: t('batch_actions.delete_confirm_message', { count: selectedEmailKeys.size }),
|
|
confirmText: isInTrash
|
|
? t('permanent_delete')
|
|
: t('batch_actions.delete'),
|
|
variant: "destructive",
|
|
});
|
|
if (!confirmed) return;
|
|
}
|
|
|
|
setIsProcessing(true);
|
|
const count = selectedEmailKeys.size;
|
|
try {
|
|
await runBatchEmailAction(() => batchDelete(client, isInTrash), {
|
|
success: tNotifications('emails_deleted', { count }),
|
|
error: tNotifications('error_deleting'),
|
|
});
|
|
} 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]);
|
|
|
|
// The messages of a thread as the list knows them, for onThreadExpand.
|
|
const threadMembers = useCallback((threadKey: string): Email[] => (
|
|
useEmailStore.getState().threadEmailsCache.get(threadKey)
|
|
?? threadGroups.find((g) => g.threadKey === threadKey)?.emails
|
|
?? []
|
|
), [threadGroups]);
|
|
|
|
const handleToggleThreadExpansion = useCallback(async (threadKey: string) => {
|
|
const isExpanded = expandedThreadIds.has(threadKey);
|
|
|
|
if (!isExpanded && client) {
|
|
toggleThreadExpansion(threadKey);
|
|
await fetchThreadEmails(client, threadKey);
|
|
emitThreadExpand(threadKey, threadMembers(threadKey), true);
|
|
// Mark the thread's unread emails read, following markAsReadDelay.
|
|
autoMarkExpandedThreadAsRead(client, threadKey);
|
|
} else {
|
|
toggleThreadExpansion(threadKey);
|
|
emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
|
|
}
|
|
}, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, autoMarkExpandedThreadAsRead, threadMembers]);
|
|
|
|
const collapseAllThreadsAndNotify = useCallback(() => {
|
|
const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
|
|
const members = wereExpanded.map((key) => [key, threadMembers(key)] as const);
|
|
collapseAllThreads();
|
|
for (const [key, emails] of members) emitThreadExpand(key, emails, false);
|
|
}, [collapseAllThreads, threadMembers]);
|
|
|
|
// Rows are memoised (MemoizedThreadListItem), which only pays off if what
|
|
// they are handed keeps its identity from one render to the next.
|
|
const rowHandlers = useStableHandlers({
|
|
onToggleExpandKey: handleToggleThreadExpansion,
|
|
onCollapseAllThreads: collapseAllThreadsAndNotify,
|
|
onEmailSelect: (email: 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(threadKeyFor(email))) {
|
|
collapseAllThreadsAndNotify();
|
|
}
|
|
}
|
|
onEmailSelect?.(email);
|
|
},
|
|
onEmailDoubleClick,
|
|
onPrefetch: onEmailPrefetch,
|
|
onContextMenu: openContextMenu,
|
|
onOpenConversation,
|
|
onToggleStar,
|
|
onMarkAsRead,
|
|
onDelete,
|
|
onArchive,
|
|
onSetTag,
|
|
onMarkAsSpam,
|
|
onUndoSpam,
|
|
onOpenAttachment,
|
|
});
|
|
// One expand toggle per thread, kept for as long as the list lives.
|
|
const toggleExpandByThread = useRef(new Map<string, () => void>());
|
|
const toggleExpandFor = (threadKey: string) => {
|
|
let toggle = toggleExpandByThread.current.get(threadKey);
|
|
if (!toggle) {
|
|
toggle = () => rowHandlers.onToggleExpandKey(threadKey);
|
|
toggleExpandByThread.current.set(threadKey, toggle);
|
|
}
|
|
return toggle;
|
|
};
|
|
|
|
// 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);
|
|
|
|
// A folded "Everything else" has nothing below to load into.
|
|
const loadsMore = !(sections && restCollapsed);
|
|
useEffect(() => {
|
|
if (lastVirtualItemIndex === undefined || !loadsMore) return;
|
|
if (lastVirtualItemIndex >= items.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, items.length, handleLoadMore, loadsMore]);
|
|
|
|
// Scroll to the thread group containing the selected email
|
|
useEffect(() => {
|
|
if (!selectedEmailKey) return;
|
|
const index = threadGroups.findIndex(thread =>
|
|
emailKeyFor(thread.latestEmail) === selectedEmailKey ||
|
|
thread.emails.some(e => emailKeyFor(e) === selectedEmailKey)
|
|
);
|
|
if (index < 0) return;
|
|
// A row clicked in the list is already on screen; scrollToIndex would
|
|
// still measure and schedule a scroll. Judge from the virtualizer's own
|
|
// numbers, which cost no layout read.
|
|
const itemIndex = itemOfThread[index];
|
|
const item = virtualizer.measurementsCache[itemIndex];
|
|
const top = virtualizer.scrollOffset;
|
|
const height = virtualizer.scrollRect?.height;
|
|
if (item && top !== null && height !== undefined && item.start >= top && item.end <= top + height) return;
|
|
virtualizer.scrollToIndex(itemIndex, { align: 'auto' });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedEmailKey]);
|
|
|
|
// Keep the moving end of a Shift+Arrow range on screen (#992).
|
|
const selectionFocusKey = useEmailStore((state) => state.selectionFocus?.focus ?? null);
|
|
useEffect(() => {
|
|
if (!selectionFocusKey) return;
|
|
const index = threadGroups.findIndex((thread) => thread.emails.some((e) => emailKeyFor(e) === selectionFocusKey));
|
|
if (index >= 0) virtualizer.scrollToIndex(itemOfThread[index], { align: 'auto' });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectionFocusKey]);
|
|
|
|
// 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
|
|
ref={batchToolbarRef}
|
|
className={cn(
|
|
"transition-all duration-300 ease-in-out overflow-hidden",
|
|
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
|
|
)}
|
|
>
|
|
<div className={cn("py-2 border-b bg-accent/30 border-border flex items-center justify-between gap-2", batchNarrow ? "px-2" : "px-4")} data-batch-narrow={batchNarrow ? "" : undefined}>
|
|
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
|
|
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate">
|
|
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
|
|
</span>
|
|
</div>
|
|
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0.5 [&_button]:px-2" : "gap-1")}>
|
|
<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>
|
|
<BatchTagButton
|
|
key={hasSelection ? 'selection' : 'none'}
|
|
title={tContextMenu('tag')}
|
|
selectedIds={selectionTags.all}
|
|
partialIds={selectionTags.some}
|
|
onToggle={handleBatchToggleTag}
|
|
active={hasSelection && !isScheduledView}
|
|
disabled={isProcessing}
|
|
/>
|
|
{effectiveMailboxRole === 'junk' && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleBatchUndoSpam}
|
|
title={tContextMenu('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>
|
|
)}
|
|
{selectionCanLeaveFolder && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleBatchRemoveFromFolder}
|
|
title={tContextMenu('remove_from_folder')}
|
|
aria-label={tContextMenu('remove_from_folder')}
|
|
disabled={isProcessing}
|
|
data-testid="batch-remove-from-folder"
|
|
className="hover:bg-accent transition-colors disabled:opacity-50"
|
|
>
|
|
{isProcessing ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<FolderX 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>
|
|
{hasSelection && (
|
|
<PluginActions
|
|
surface="message-bulk-actions"
|
|
context={bulkActionContext}
|
|
mailboxRole={effectiveMailboxRole ?? null}
|
|
compact
|
|
overflow={batchNarrow}
|
|
className="flex-nowrap"
|
|
/>
|
|
)}
|
|
<div className={cn("w-px h-6 bg-border", batchNarrow ? "mx-0.5" : "mx-1")} />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={clearSelection}
|
|
title={t('batch_actions.clear_selection')}
|
|
aria-label={t('batch_actions.clear_selection')}
|
|
disabled={isProcessing}
|
|
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
{batchNarrow ? <X className="w-4 h-4" /> : 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>
|
|
)}
|
|
|
|
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
|
|
|
|
<PluginSearchResults surface="search-results" onOpenEmail={onOpenSearchResult} />
|
|
|
|
{listColumns && (
|
|
<ListColumnsHeader density={density} selecting={hasSelection} showRecipient={showRecipientColumn} />
|
|
)}
|
|
|
|
{/* Email List */}
|
|
<div
|
|
ref={parentRef}
|
|
className="flex-1 overflow-y-auto bg-background relative"
|
|
data-tour="email-list"
|
|
// The column header above keeps the same gutter, so its titles stay over the columns.
|
|
style={listColumns ? { scrollbarGutter: 'stable' } : undefined}
|
|
>
|
|
{/* 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>
|
|
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
|
|
</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 listItem = items[virtualItem.index];
|
|
const thread = listItem?.kind === 'row' ? listItem.thread : null;
|
|
return (
|
|
<div
|
|
key={virtualItem.key}
|
|
data-index={virtualItem.index}
|
|
ref={virtualizer.measureElement}
|
|
style={{
|
|
position: 'absolute',
|
|
// `top`, not translateY: layout snaps it to device
|
|
// pixels, so the fractional offsets stay crisp.
|
|
top: virtualItem.start,
|
|
left: 0,
|
|
width: '100%',
|
|
}}
|
|
>
|
|
{listItem?.kind === 'header' && unreadSection && (
|
|
listItem.section === 'unread' ? (
|
|
<MvListSectionHeader
|
|
title={t('sections.unread')}
|
|
collapsed={unreadCollapsed}
|
|
onToggle={() => updateSetting('mvUnreadSectionCollapsed', !unreadCollapsed)}
|
|
shown={sectionRowCount}
|
|
total={unreadSection.total}
|
|
/>
|
|
) : (
|
|
<MvListSectionHeader
|
|
title={t('sections.everything_else')}
|
|
collapsed={restCollapsed}
|
|
onToggle={() => updateSetting('mvEverythingElseCollapsed', !restCollapsed)}
|
|
shown={emails.length - sectionRowCount}
|
|
total={totalEmails}
|
|
/>
|
|
)
|
|
)}
|
|
{listItem?.kind === 'unread-empty' && (
|
|
<p className="flex h-12 items-center border-b border-border px-6 text-sm text-muted-foreground">
|
|
{t('sections.unread_empty')}
|
|
</p>
|
|
)}
|
|
{thread && <MemoizedThreadListItem
|
|
thread={thread}
|
|
isExpanded={expandedThreadIds.has(thread.threadKey)}
|
|
selectedEmailKey={selectedEmailKey}
|
|
isLoading={isLoadingThread === thread.threadKey}
|
|
expandedEmails={threadEmailsCache.get(thread.threadKey)}
|
|
onToggleExpand={toggleExpandFor(thread.threadKey)}
|
|
onCollapseAllThreads={rowHandlers.onCollapseAllThreads}
|
|
onEmailSelect={rowHandlers.onEmailSelect}
|
|
onEmailDoubleClick={rowHandlers.onEmailDoubleClick}
|
|
onPrefetch={rowHandlers.onPrefetch}
|
|
onContextMenu={rowHandlers.onContextMenu}
|
|
onOpenConversation={rowHandlers.onOpenConversation}
|
|
onToggleStar={rowHandlers.onToggleStar}
|
|
onMarkAsRead={rowHandlers.onMarkAsRead}
|
|
onDelete={rowHandlers.onDelete}
|
|
onArchive={rowHandlers.onArchive}
|
|
onSetTag={rowHandlers.onSetTag}
|
|
onMarkAsSpam={rowHandlers.onMarkAsSpam}
|
|
onUndoSpam={rowHandlers.onUndoSpam}
|
|
onOpenAttachment={rowHandlers.onOpenAttachment}
|
|
loadAttachments={loadAttachments}
|
|
/>}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{loadsMore && <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 */}
|
|
{contextMenuEmail && (
|
|
<EmailContextMenu
|
|
email={contextMenuEmail}
|
|
position={contextMenu.position}
|
|
isOpen={contextMenu.isOpen}
|
|
onClose={closeContextMenu}
|
|
menuRef={menuRef}
|
|
mailboxes={mailboxes}
|
|
selectedMailbox={selectedMailbox}
|
|
currentMailboxRole={effectiveMailboxRole}
|
|
isMultiSelect={selectedEmailKeys.has(emailKeyFor(contextMenuEmail))}
|
|
selectedCount={selectedEmailKeys.size}
|
|
ruleEmails={ruleEmails}
|
|
onReply={() => onReply?.(contextMenuEmail!)}
|
|
onReplyAll={() => onReplyAll?.(contextMenuEmail!)}
|
|
onForward={() => onForward?.(contextMenuEmail!)}
|
|
onForwardAsAttachment={() => onForwardAsAttachment?.(contextMenuEmail!)}
|
|
onFindFromSender={onFindFromSender && contextMenuEmail.from?.[0]?.email
|
|
? () => onFindFromSender({ name: contextMenuEmail!.from![0].name || '', email: contextMenuEmail!.from![0].email })
|
|
: undefined}
|
|
onBatchForwardAsAttachment={onBatchForwardAsAttachment}
|
|
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, emailKeyFor(contextMenuEmail!))}
|
|
onMoveToMailbox={(mailboxId) => onMoveToMailbox?.(contextMenuEmail!.id, mailboxId, emailKeyFor(contextMenuEmail!))}
|
|
onRemoveFromFolder={() => void removeFromFolder([contextMenuEmail!])}
|
|
onMarkAsSpam={() => onMarkAsSpam?.(contextMenuEmail!)}
|
|
onUndoSpam={() => onUndoSpam?.(contextMenuEmail!)}
|
|
onEditDraft={() => onEditDraft?.(contextMenuEmail!)}
|
|
onCancelScheduledForEdit={onCancelScheduledForEdit ? () => onCancelScheduledForEdit(contextMenuEmail!) : undefined}
|
|
onRescheduleScheduled={onRescheduleScheduled ? () => onRescheduleScheduled(contextMenuEmail!) : undefined}
|
|
copyTargets={copyTargets}
|
|
onCopyToAccount={async (accountId, mailboxId) => {
|
|
// Pass the emails, not their ids: an id alone cannot say which
|
|
// account's namesake to copy.
|
|
const ids = selectedEmailKeys.has(emailKeyFor(contextMenuEmail!)) && selectedEmailKeys.size > 1
|
|
? resolveSelectedEmails(useEmailStore.getState())
|
|
: [contextMenuEmail!];
|
|
const target = accountMailboxes[accountId]?.find((mb) => mb.id === mailboxId);
|
|
await runBatchEmailAction(() => copyEmailsToAccount(ids, accountId, target?.originalId ?? mailboxId), {
|
|
success: tNotifications('emails_copied', { count: ids.length }),
|
|
error: tNotifications('copy_failed'),
|
|
});
|
|
}}
|
|
onBatchMarkAsRead={(read) => markSelectionRead(read)}
|
|
batchTagIds={selectionTags.all}
|
|
batchPartialTagIds={selectionTags.some}
|
|
onBatchToggleTag={handleBatchToggleTag}
|
|
onBatchDelete={async () => {
|
|
if (!client) return;
|
|
const count = selectedEmailKeys.size;
|
|
await runBatchEmailAction(() => batchDelete(client), {
|
|
success: tNotifications('emails_deleted', { count }),
|
|
error: tNotifications('error_deleting'),
|
|
});
|
|
}}
|
|
onBatchArchive={async () => {
|
|
if (!client) return;
|
|
const count = selectedEmailKeys.size;
|
|
await runBatchEmailAction(() => batchArchive(client), {
|
|
success: tNotifications('emails_archived', { count }),
|
|
error: tNotifications('error_archiving'),
|
|
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
|
|
? tViewer('archive_mailbox_not_found')
|
|
: undefined,
|
|
});
|
|
}}
|
|
batchEmails={selectedEmailsForRemoval}
|
|
onBatchRemoveFromFolder={() => void handleBatchRemoveFromFolder()}
|
|
onBatchMoveToMailbox={async (mailboxId) => {
|
|
if (!client) return;
|
|
const count = selectedEmailKeys.size;
|
|
await runBatchEmailAction(() => batchMoveToMailbox(client, mailboxId), {
|
|
success: tNotifications('emails_moved', { count }),
|
|
error: tNotifications('move_failed'),
|
|
});
|
|
}}
|
|
onBatchMarkAsSpam={async () => {
|
|
if (client) {
|
|
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
|
try {
|
|
if (await batchMarkAsSpam(client, emailIds)) toast.success(
|
|
tSpam('toast_batch', { count: emailIds.length })
|
|
);
|
|
} catch {
|
|
toast.error(tSpam('error'));
|
|
}
|
|
}
|
|
}}
|
|
onBatchUndoSpam={async () => {
|
|
if (client) {
|
|
const emailIds = resolveSelectedEmails(useEmailStore.getState()).map(e => e.id);
|
|
try {
|
|
if (await batchUndoSpam(client, emailIds)) toast.success(
|
|
tSpam('toast_not_spam_batch', { count: emailIds.length })
|
|
);
|
|
} catch {
|
|
toast.error(tSpam('error_not_spam'));
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<ConfirmDialog {...confirmDialogProps} />
|
|
</div>
|
|
</TagDisplayContext.Provider>
|
|
);
|
|
}
|