From 30e8cd02a4b9556778d034f49d71a12d8fc5a9e7 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 11:36:55 +0100
Subject: [PATCH] feat: select every loaded conversation with one click and
offer the rest of the folder
---
.../__tests__/email-list-batch-bar.test.tsx | 22 ++++++
components/email/email-list.tsx | 69 ++++++++++++++++++-
.../mail/__tests__/mail-app-list-ux.test.tsx | 12 ++++
components/mail/mail-app.tsx | 21 ++----
4 files changed, 108 insertions(+), 16 deletions(-)
diff --git a/components/email/__tests__/email-list-batch-bar.test.tsx b/components/email/__tests__/email-list-batch-bar.test.tsx
index c44dc2df3..67bddb70b 100644
--- a/components/email/__tests__/email-list-batch-bar.test.tsx
+++ b/components/email/__tests__/email-list-batch-bar.test.tsx
@@ -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();
+ 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();
+ 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');
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index de0de91fe..c8d3fa426 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -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({
)}
+ {hasSelection && !isScheduledView && !hideBatchToolbar && (offerSelectAll || selectingAll) && (
+
+
+
+ )}
{/* Advanced Search Filter Chips */}
diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx
index b209cea91..54354744c 100644
--- a/components/mail/__tests__/mail-app-list-ux.test.tsx
+++ b/components/mail/__tests__/mail-app-list-ux.test.tsx
@@ -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();
+ 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'],
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 556e6e1d1..59c95a26f 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -5672,24 +5672,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Select / Select All toggle */}
{
- 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 ? : }