feat: select every loaded conversation with one click and offer the rest of the folder

This commit is contained in:
Linus Rath
2026-11-04 11:36:55 +01:00
parent f84cc909d5
commit 30e8cd02a4
4 changed files with 108 additions and 16 deletions
@@ -108,6 +108,28 @@ describe('selection bar: Move, read state and the reading pane', () => {
});
});
describe('select all in the folder (mail-p2-11)', () => {
it('offers the rest of the folder once every loaded row is ticked, and ticks it', async () => {
const third = { ...useEmailStore.getState().emails[0], id: 'three', threadId: 't-three', blobId: 'b-three' } as Email;
const loadMoreEmails = vi.fn(async () => {
useEmailStore.setState({ emails: [...useEmailStore.getState().emails, third], hasMoreEmails: false });
});
useEmailStore.setState({ hasMoreEmails: true, loadMoreEmails, totalEmails: 3 });
render(<EmailList emails={useEmailStore.getState().emails} />);
const offer = screen.getByTestId('select-all-in-view');
expect(offer.textContent).toBe('mail_p2_list.select_all_in_folder');
fireEvent.click(offer.querySelector('button')!);
await waitFor(() => expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['one', 'three', 'two']));
expect(loadMoreEmails).toHaveBeenCalledTimes(1);
});
it('offers nothing while only some loaded rows are ticked', () => {
useEmailStore.setState({ hasMoreEmails: true, selectedEmailKeys: new Set(['one']) });
render(<EmailList emails={useEmailStore.getState().emails} />);
expect(screen.queryByTestId('select-all-in-view')).toBeNull();
});
});
describe('the reading pane for a selection (mail-p2-15)', () => {
it('names its buttons in words, while the bar beside the list keeps icons', () => {
const host = document.createElement('div');
+68 -1
View File
@@ -143,6 +143,9 @@ type ListItem =
| { kind: 'header'; section: 'unread' | 'rest' }
| { kind: 'unread-empty' };
/** The most messages "Select all N conversations" loads to tick them (mail-p2-11). */
const SELECT_ALL_LIMIT = 5000;
/** Below this width the batch toolbar drops its labels (see batchNarrow). */
const BATCH_TOOLBAR_WIDE = 640;
@@ -848,6 +851,57 @@ export function EmailList({
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isEmptyableFolder = currentMailbox?.role === 'trash' || currentMailbox?.role === 'junk';
// Every loaded row ticked and more on the server: offer the rest, as Gmail
// does (mail-p2-11). Taking it loads the remaining pages and ticks them,
// so the batch actions, their Undo and their counts work as for any
// selection. At most SELECT_ALL_LIMIT messages are loaded for it.
const [selectingAll, setSelectingAll] = useState(false);
const allLoadedSelected = hasSelection && emails.length > 0 && emails.every((e) => selectedEmailKeys.has(emailKeyFor(e)));
const offerSelectAll = allLoadedSelected && footerHasMore && !isScheduledView && !hideBatchToolbar && !!client
&& emails.length < SELECT_ALL_LIMIT;
const inPlainFolder = !!currentMailbox && !searchQuery.trim() && isFilterEmpty(searchFilters) && !isUnifiedView;
const viewTotal = Math.max(
inPlainFolder
? (disableThreading ? currentMailbox.totalEmails : (currentMailbox.totalThreads || currentMailbox.totalEmails))
: totalEmails,
selectionRows + 1,
);
const selectAllLabel = inPlainFolder
? tRoot(disableThreading ? 'mail_p2_list.select_all_emails_in_folder' : 'mail_p2_list.select_all_in_folder', {
count: viewTotal,
folder: localizeMailboxName(currentMailbox.role, currentMailbox.name, (k) => tSidebar(`mailboxes.${k}`)),
})
: tRoot(disableThreading ? 'mail_p2_list.select_all_emails' : 'mail_p2_list.select_all_conversations', { count: viewTotal });
const handleSelectAllInView = async () => {
if (!client || selectingAll) return;
const view = () => {
const state = useEmailStore.getState();
return `${state.selectedMailbox}|${state.searchQuery}|${state.isUnifiedView}|${state.unifiedRole}`;
};
const startedIn = view();
setSelectingAll(true);
try {
for (let pages = 0; pages < 1000; pages++) {
const state = useEmailStore.getState();
if (view() !== startedIn || state.selectedEmailKeys.size === 0) return;
if (!state.hasMoreEmails || state.emails.length >= SELECT_ALL_LIMIT) break;
if (state.isLoadingMore) {
await new Promise((resolve) => setTimeout(resolve, 50));
continue;
}
const before = state.emails.length;
await state.loadMoreEmails(client);
const after = useEmailStore.getState();
if (after.emails.length === before && !after.isLoadingMore) break;
}
// Another folder opened or the selection was cleared meanwhile: leave it.
if (view() !== startedIn || useEmailStore.getState().selectedEmailKeys.size === 0) return;
useEmailStore.getState().selectAllEmails();
} finally {
setSelectingAll(false);
}
};
const handleEmptyFolder = async () => {
if (!client || isProcessing || !currentMailbox) return;
@@ -1122,7 +1176,7 @@ export function EmailList({
inert={!(hasSelection && !isScheduledView && !hideBatchToolbar)}
className={cn(
"transition-all duration-300 ease-in-out overflow-hidden",
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
hasSelection && !isScheduledView && !hideBatchToolbar ? (offerSelectAll || selectingAll ? "max-h-28 opacity-100" : "max-h-16 opacity-100") : "max-h-0 opacity-0"
)}
>
{/* Only drawn while shown: collapsed it would still render every button. */}
@@ -1218,6 +1272,19 @@ export function EmailList({
</div>
</div>
)}
{hasSelection && !isScheduledView && !hideBatchToolbar && (offerSelectAll || selectingAll) && (
<div className="px-4 py-1.5 border-b border-border bg-accent/20 text-center text-sm" data-testid="select-all-in-view">
<button
type="button"
onClick={() => void handleSelectAllInView()}
disabled={selectingAll}
aria-busy={selectingAll || undefined}
className="text-primary font-medium hover:underline disabled:no-underline disabled:opacity-70"
>
{selectingAll ? tRoot('mail_p2_list.selecting_all') : selectAllLabel}
</button>
</div>
)}
</div>
{/* Advanced Search Filter Chips */}
@@ -174,6 +174,18 @@ describe('the reading pane for a selection', () => {
});
});
describe('the list header checkbox (mail-p2-11)', () => {
it('ticks every loaded row on the first click and clears them on the second', () => {
const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email;
useEmailStore.setState({ emails: [message, second] } as never);
render(<MailApp />);
fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ }));
expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']);
fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' }));
expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
});
});
describe('the new folder dialogs', () => {
it.each([
['stub new folder', 'mail_list_extra.new_folder_title'],
+6 -15
View File
@@ -5672,24 +5672,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Select / Select All toggle */}
<ToolbarButton
onClick={() => {
if (selectedEmailKeys.size > 0) {
if (selectedEmailKeys.size === activeEmails.length) {
clearSelection();
} else {
selectAllEmails();
}
} else if (activeEmails.length > 0) {
// The open message when it is in this list, else the
// first row - as an email, so the selection is keyed by
// its owning account and not by a colliding id.
const openKey = selectedEmail ? emailKeyFor(selectedEmail) : null;
const target = activeEmails.find((e) => emailKeyFor(e) === openKey) ?? activeEmails[0];
toggleEmailSelection(target);
}
// One click ticks every loaded row, a second clears
// them, as Gmail's box does (mail-p2-11). The bar then
// offers the rest of the folder.
if (selectedEmailKeys.size > 0) clearSelection();
else if (activeEmails.length > 0) selectAllEmails();
}}
disabled={isScheduledView}
active={selectedEmailKeys.size > 0}
label={selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
label={selectedEmailKeys.size > 0 ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
>
{selectedEmailKeys.size > 0 ? <CheckSquare /> : <Square />}