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 ? : }