feat: complete multi-selection store methods, toolbar integration, and shift-click support (#43)

This commit is contained in:
Matthieu MALVACHE
2026-03-23 13:02:58 +01:00
parent 6fc7217185
commit ae92bd2cbc
2 changed files with 142 additions and 54 deletions
+74 -27
View File
@@ -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 (
<div className={cn("flex flex-col h-full", className)}>
{/* Batch Actions Toolbar */}
@@ -223,8 +277,8 @@ export function EmailList({
>
<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">
{selectedEmailIds.size} {selectedEmailIds.size === 1 ? 'email' : 'emails'} selected
<span className="text-sm font-medium text-foreground" aria-live="polite">
{t('batch_actions.selected_count', { count: selectedEmailIds.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
@@ -256,6 +310,12 @@ export function EmailList({
<Mail className="w-4 h-4" />
)}
</Button>
<MoveToPopover
mailboxes={mailboxes}
currentMailboxId={selectedMailbox}
onMove={handleBatchMove}
disabled={isProcessing}
/>
<Button
variant="ghost"
size="sm"
@@ -279,7 +339,7 @@ export function EmailList({
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
Cancel
{t('batch_actions.clear_selection')}
</Button>
</div>
</div>
@@ -304,22 +364,12 @@ export function EmailList({
{/* List Header */}
<div className="px-4 py-3 border-b bg-muted/50 border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<button
onClick={() => allSelected ? clearSelection() : selectAllEmails()}
className={cn(
"p-1 rounded transition-all duration-200",
"hover:bg-muted hover:scale-110",
"active:scale-95",
allSelected && "text-primary"
)}
title={allSelected ? "Deselect all" : "Select all"}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 animate-in zoom-in-50 duration-200" />
) : (
<Square className="w-4 h-4" />
)}
</button>
<SelectionDropdown
hasSelection={hasSelection}
allSelected={allSelected}
onSelectByFilter={(filter, groups) => selectByFilter(filter, groups)}
threadGroups={threadGroups}
/>
<h2 className="text-sm font-medium text-foreground">
{isLoading ? t('loading') : threadGroups.length > 0
? (totalEmails !== undefined && totalEmails > threadGroups.length
@@ -333,7 +383,7 @@ export function EmailList({
</div>
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative">
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" tabIndex={0}>
{/* 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">
@@ -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)}
/>
</div>
);
+68 -27
View File
@@ -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<string>; // Track emails being marked as read/unread
selectedEmailIds: Set<string>; // 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<void>;
@@ -113,6 +116,7 @@ export const useEmailStore = create<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<string>();
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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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<EmailStore>((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 {
}
},