mirror of
https://github.com/root-fr/jmap-webmail.git
synced 2026-09-25 16:01:22 +00:00
feat: complete multi-selection store methods, toolbar integration, and shift-click support (#43)
This commit is contained in:
@@ -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
@@ -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 {
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user