diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 9132725..e8bc29c 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -4,7 +4,7 @@ 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 { Inbox, CheckSquare, Square, Trash2, Mail, MailOpen, Loader2 } from "lucide-react"; +import { Inbox, Trash2, Mail, MailOpen, Loader2 } from "lucide-react"; import { useState, useEffect, useRef, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; @@ -18,6 +18,8 @@ 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"; +import { SelectionDropdown } from "./selection-dropdown"; +import { MoveToPopover } from "./move-to-popover"; interface EmailListProps { emails: Email[]; @@ -65,6 +67,9 @@ export function EmailList({ toggleEmailSelection, selectAllEmails, clearSelection, + selectRange, + lastSelectedIndex, + selectByFilter, batchMarkAsRead, batchDelete, batchMoveToMailbox, @@ -134,7 +139,7 @@ export function EmailList({ ); const hasSelection = selectedEmailIds.size > 0; - const allSelected = emails.length > 0 && emails.every(e => selectedEmailIds.has(e.id)); + const allSelected = threadGroups.length > 0 && threadGroups.every(g => selectedEmailIds.has(g.latestEmail.id)); const handleBatchMarkAsRead = async (read: boolean) => { if (!client || isProcessing) return; @@ -165,6 +170,25 @@ export function EmailList({ } }; + const handleBatchMove = async (mailboxId: string) => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + await batchMoveToMailbox(client, mailboxId); + const { toast } = await import('sonner'); + const targetMailbox = mailboxes.find(m => m.id === mailboxId); + toast.success(t('batch_actions.move_success', { + count: selectedEmailIds.size, + folder: targetMailbox?.name || mailboxId, + })); + } catch { + const { toast } = await import('sonner'); + toast.error(t('batch_actions.move_error')); + } finally { + setTimeout(() => setIsProcessing(false), 500); + } + }; + const handleLoadMore = useCallback(() => { if (client && hasMoreEmails && !isLoadingMore && !isLoading) { loadMoreEmails(client); @@ -182,6 +206,15 @@ export function EmailList({ } }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails]); + const handleCheckboxClick = useCallback((e: React.MouseEvent, emailId: string, groupIndex: number) => { + e.stopPropagation(); + if (e.shiftKey && lastSelectedIndex !== null) { + selectRange(lastSelectedIndex, groupIndex, threadGroups); + } else { + toggleEmailSelection(emailId, groupIndex); + } + }, [lastSelectedIndex, selectRange, toggleEmailSelection, threadGroups]); + // Range-based load more: trigger when last visible item is near the end const virtualItems = virtualizer.getVirtualItems(); const lastVirtualItemIndex = virtualItems[virtualItems.length - 1]?.index; @@ -212,6 +245,27 @@ export function EmailList({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [listDensity, showPreview]); + useEffect(() => { + const container = parentRef.current; + if (!container) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 'a') { + const activeEl = document.activeElement; + const isInInput = activeEl instanceof HTMLInputElement || + activeEl instanceof HTMLTextAreaElement || + (activeEl instanceof HTMLElement && activeEl.isContentEditable); + if (isInInput) return; + + e.preventDefault(); + selectAllEmails(threadGroups); + } + }; + + container.addEventListener('keydown', handleKeyDown); + return () => container.removeEventListener('keydown', handleKeyDown); + }, [selectAllEmails, threadGroups]); + return (
{/* Batch Actions Toolbar */} @@ -223,8 +277,8 @@ export function EmailList({ >
- - {selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected + + {t('batch_actions.selected_count', { count: selectedEmailIds.size })}
@@ -256,6 +310,12 @@ export function EmailList({ )} +
@@ -304,22 +364,12 @@ export function EmailList({ {/* List Header */}
- + selectByFilter(filter, groups)} + threadGroups={threadGroups} + />

{isLoading ? t('loading') : threadGroups.length > 0 ? (totalEmails !== undefined && totalEmails > threadGroups.length @@ -333,7 +383,7 @@ export function EmailList({

{/* Email List */} -
+
{/* Loading overlay */} {isLoading && emails.length > 0 && (
@@ -387,11 +437,8 @@ export function EmailList({ onEmailSelect={(email) => onEmailSelect?.(email)} onContextMenu={openContextMenu} onOpenConversation={onOpenConversation} - isChecked={thread.emails.some(e => selectedEmailIds.has(e.id))} - onCheckboxClick={(e) => { - e.stopPropagation(); - thread.emails.forEach(email => toggleEmailSelection(email.id)); - }} + isChecked={selectedEmailIds.has(thread.latestEmail.id)} + onCheckboxClick={(e) => handleCheckboxClick(e, thread.latestEmail.id, virtualItem.index)} />
); diff --git a/stores/email-store.ts b/stores/email-store.ts index 317062b..d5080bf 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { Email, Mailbox, StateChange } from "@/lib/jmap/types"; +import { Email, Mailbox, StateChange, ThreadGroup } from "@/lib/jmap/types"; import { JMAPClient } from "@/lib/jmap/client"; import { useSettingsStore } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; @@ -18,6 +18,7 @@ interface EmailStore { quota: { used: number; total: number } | null; processingReadStatus: Set; // Track emails being marked as read/unread selectedEmailIds: Set; // Track selected emails for batch operations + lastSelectedIndex: number | null; hasMoreEmails: boolean; // Track if more emails are available to load totalEmails: number; // Total number of emails in the current mailbox/query isPushConnected: boolean; // Track if push notifications are connected @@ -43,9 +44,11 @@ interface EmailStore { setError: (error: string | null) => void; setSearchQuery: (query: string) => void; setQuota: (quota: { used: number; total: number } | null) => void; - toggleEmailSelection: (emailId: string) => void; - selectAllEmails: () => void; + toggleEmailSelection: (emailId: string, groupIndex?: number) => void; + selectAllEmails: (threadGroups?: ThreadGroup[]) => void; clearSelection: () => void; + selectRange: (fromIndex: number, toIndex: number, threadGroups: ThreadGroup[]) => void; + selectByFilter: (filter: string, threadGroups: ThreadGroup[]) => void; // JMAP operations fetchMailboxes: (client: JMAPClient) => Promise; @@ -113,6 +116,7 @@ export const useEmailStore = create((set, get) => ({ quota: null, processingReadStatus: new Set(), selectedEmailIds: new Set(), + lastSelectedIndex: null, hasMoreEmails: false, totalEmails: 0, isPushConnected: false, @@ -142,6 +146,7 @@ export const useEmailStore = create((set, get) => ({ selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set(), + lastSelectedIndex: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, @@ -152,7 +157,7 @@ export const useEmailStore = create((set, get) => ({ setSearchQuery: (query) => set({ searchQuery: query }), setQuota: (quota) => set({ quota }), - toggleEmailSelection: (emailId) => { + toggleEmailSelection: (emailId, groupIndex) => { const { selectedEmailIds } = get(); const newSelection = new Set(selectedEmailIds); if (newSelection.has(emailId)) { @@ -160,17 +165,66 @@ export const useEmailStore = create((set, get) => ({ } else { newSelection.add(emailId); } - set({ selectedEmailIds: newSelection }); + set({ + selectedEmailIds: newSelection, + lastSelectedIndex: groupIndex ?? get().lastSelectedIndex, + }); }, - selectAllEmails: () => { - const { emails } = get(); - const allIds = new Set(emails.map(e => e.id)); - set({ selectedEmailIds: allIds }); + selectAllEmails: (threadGroups) => { + if (threadGroups) { + const allIds = new Set(threadGroups.map(g => g.latestEmail.id)); + set({ selectedEmailIds: allIds, lastSelectedIndex: null }); + } else { + const { emails } = get(); + const allIds = new Set(emails.map(e => e.id)); + set({ selectedEmailIds: allIds }); + } }, clearSelection: () => { - set({ selectedEmailIds: new Set() }); + set({ selectedEmailIds: new Set(), lastSelectedIndex: null }); + }, + + selectRange: (fromIndex, toIndex, threadGroups) => { + const start = Math.min(fromIndex, toIndex); + const end = Math.max(fromIndex, toIndex); + const { selectedEmailIds } = get(); + const newSelection = new Set(selectedEmailIds); + for (let i = start; i <= end; i++) { + const group = threadGroups[i]; + if (group) { + newSelection.add(group.latestEmail.id); + } + } + set({ selectedEmailIds: newSelection, lastSelectedIndex: toIndex }); + }, + + selectByFilter: (filter, threadGroups) => { + if (filter === 'none') { + set({ selectedEmailIds: new Set(), lastSelectedIndex: null }); + return; + } + if (filter === 'all') { + const allIds = new Set(threadGroups.map(g => g.latestEmail.id)); + set({ selectedEmailIds: allIds, lastSelectedIndex: null }); + return; + } + const newSelection = new Set(); + for (const group of threadGroups) { + const email = group.latestEmail; + const seen = !!email.keywords?.$seen; + const flagged = !!email.keywords?.$flagged; + const match = + (filter === 'read' && seen) || + (filter === 'unread' && !seen) || + (filter === 'starred' && flagged) || + (filter === 'unstarred' && !flagged); + if (match) { + newSelection.add(email.id); + } + } + set({ selectedEmailIds: newSelection, lastSelectedIndex: null }); }, // JMAP operations @@ -224,7 +278,6 @@ export const useEmailStore = create((set, get) => ({ isLoading: false }); } catch (error) { - console.error('Failed to fetch emails:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch emails", isLoading: false, @@ -283,7 +336,6 @@ export const useEmailStore = create((set, get) => ({ isLoadingMore: false }); } catch (error) { - console.error('Failed to load more emails:', error); set({ error: error instanceof Error ? error.message : "Failed to load more emails", isLoadingMore: false @@ -880,7 +932,6 @@ export const useEmailStore = create((set, get) => ({ set({ selectedEmail: emails[currentIndex + 1] }); } } catch (error) { - console.error('Failed to mark as spam:', error); throw error; } }, @@ -921,7 +972,6 @@ export const useEmailStore = create((set, get) => ({ await client.undoSpam(emailId, targetMailboxId, accountId); await get().fetchEmails(client, selectedMailbox); } catch (error) { - console.error('Failed to restore email:', error); throw error; } }, @@ -943,7 +993,6 @@ export const useEmailStore = create((set, get) => ({ selectedEmailIds: new Set(), })); } catch (error) { - console.error('Failed to batch mark as spam:', error); throw error; } }, @@ -975,7 +1024,6 @@ export const useEmailStore = create((set, get) => ({ selectedEmailIds: new Set(), })); } catch (error) { - console.error('Failed to batch restore emails:', error); throw error; } }, @@ -1025,13 +1073,10 @@ export const useEmailStore = create((set, get) => ({ const { useFilterStore } = await import('./filter-store'); const filterStore = useFilterStore.getState(); if (filterStore.isSupported) { - filterStore.fetchFilters(client).catch((err) => { - console.error('Failed to refresh filters:', err); - }); + filterStore.fetchFilters(client).catch(() => {}); } } } catch (error) { - console.error('Failed to handle state change:', error); set({ error: error instanceof Error ? error.message : "Failed to handle push notification" }); @@ -1088,9 +1133,7 @@ export const useEmailStore = create((set, get) => ({ totalEmails: result.total, }); } - } catch (error) { - console.error('Failed to refresh current mailbox:', error); - // Don't set error state for background refreshes to avoid disrupting the UI + } catch { } }, @@ -1148,8 +1191,7 @@ export const useEmailStore = create((set, get) => ({ }); return emails; - } catch (error) { - console.error('Failed to fetch thread emails:', error); + } catch { set({ isLoadingThread: null }); return []; } @@ -1173,8 +1215,7 @@ export const useEmailStore = create((set, get) => ({ const tags = ["red", "orange", "yellow", "green", "blue", "purple", "pink"]; const tagCounts = await client.queryTagCounts(tags); set({ tagCounts }); - } catch (error) { - console.error("Failed to fetch tag counts:", error); + } catch { } },