diff --git a/src/lib/__tests__/selection-toolbar.test.ts b/src/lib/__tests__/selection-toolbar.test.ts new file mode 100644 index 00000000..90d9adda --- /dev/null +++ b/src/lib/__tests__/selection-toolbar.test.ts @@ -0,0 +1,49 @@ +// Tablet portrait: the selection toolbar ran past the list column and put +// Archive and Delete over the reading pane. +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fitToolbarActions } from '../selection-toolbar'; + +const ALL = ['star', 'read', 'tag', 'move', 'spam', 'archive', 'delete'] as const; +const opts = (width: number | null) => ({ + width, + // EmailListScreen: 8 + 8 padding, 44 close, 24 + 4 + 4 count. + reserved: 92, + button: 44, + overflowOrder: ['spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'] as const, +}); + +describe('fitToolbarActions', () => { + it('shows every action where they fit (a 412 dp phone)', () => { + expect(fitToolbarActions(ALL, opts(412))).toEqual({ visible: [...ALL], overflow: [] }); + }); + + it('moves the least used into "More" on a narrow list column, keeping Archive and Delete', () => { + const fit = fitToolbarActions(ALL, opts(360)); + expect(fit.overflow).toEqual(['tag', 'spam']); + expect(fit.visible).toEqual(['star', 'read', 'move', 'archive', 'delete']); + // The bar, its "More" button included, fits the column. + expect(92 + (fit.visible.length + 1) * 44).toBeLessThanOrEqual(360); + }); + + it('never needs more room than the column has', () => { + for (let width = 150; width <= 500; width += 7) { + const fit = fitToolbarActions(ALL, opts(width)); + const buttons = fit.visible.length + (fit.overflow.length > 0 ? 1 : 0); + if (width >= 92 + 44) expect(92 + buttons * 44).toBeLessThanOrEqual(width); + expect([...fit.visible, ...fit.overflow].sort()).toEqual([...ALL].sort()); + } + }); + + it('shows them all until the width is measured', () => { + expect(fitToolbarActions(ALL, opts(null)).overflow).toEqual([]); + }); + + it('is what the list header renders', () => { + const src = fs.readFileSync(path.resolve(__dirname, '../../screens/EmailListScreen.tsx'), 'utf8'); + expect(src).toMatch(/onLayout=\{\(e\) => setSelectionBarWidth\(e\.nativeEvent\.layout\.width\)\}/); + expect(src).toMatch(/toolbarFit\.visible\.map\(/); + expect(src).toMatch(/items=\{toolbarFit\.overflow\.map\(/); + }); +}); diff --git a/src/lib/selection-toolbar.ts b/src/lib/selection-toolbar.ts new file mode 100644 index 00000000..1a7accbe --- /dev/null +++ b/src/lib/selection-toolbar.ts @@ -0,0 +1,32 @@ +// The list's selection toolbar on a narrow list column. Up to seven 44 dp +// actions sit beside the close button and the count; in a tablet's portrait +// split view the list column is narrower than that, and Archive and Delete +// were drawn over the reading pane. The actions that don't fit go into a +// "More" menu, the least used first, so Archive and Delete stay on the bar. + +/** Which actions to show on the bar and which to put in the "More" menu. */ +export function fitToolbarActions( + keys: readonly K[], + opts: { + /** The toolbar's measured width; null shows them all until it is known. */ + width: number | null; + /** Room taken by everything that isn't an action (padding, close, count). */ + reserved: number; + /** Width of one action button (and of the "More" button). */ + button: number; + /** The actions to move into the menu, first to go first. */ + overflowOrder: readonly K[]; + }, +): { visible: K[]; overflow: K[] } { + if (opts.width === null) return { visible: [...keys], overflow: [] }; + const capacity = Math.max(0, Math.floor((opts.width - opts.reserved) / opts.button)); + if (keys.length <= capacity) return { visible: [...keys], overflow: [] }; + // One slot goes to the "More" button. + const room = Math.max(0, capacity - 1); + const order = [...opts.overflowOrder.filter((k) => keys.includes(k)), ...keys.filter((k) => !opts.overflowOrder.includes(k))]; + const moved = new Set(order.slice(0, keys.length - room)); + return { + visible: keys.filter((k) => !moved.has(k)), + overflow: keys.filter((k) => moved.has(k)), + }; +} diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index f5921119..16200da7 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -7,7 +7,7 @@ import { Search, SquarePen, Menu, Filter, Square, SquareCheck, Minus, X, Star, Paperclip, Mail as MailIcon, MailOpen, Trash2, RotateCcw, CalendarDays, Archive, FolderInput, Tag, Import, ArrowDownWideNarrow, ArrowUpNarrowWide, - Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe, + Pin, Reply, Forward, ShieldAlert, ShieldCheck, Folder, ArrowUpDown, Check, Globe, MoreVertical, } from '../components/icons'; import { GlobalSearchSheet } from '../components/search/GlobalSearchSheet'; import { ActionSheet } from '../components/email/ActionSheet'; @@ -90,6 +90,7 @@ import type { Attachment, Email } from '../api/types'; import { createFrameMonitor, logModuleTimes, perfOnce, perfStart } from '../lib/perf-marks'; import { MODAL_ORIENTATIONS } from '../lib/modal-orientations'; import { searchEverythingPlacement } from '../lib/search-everything-entry'; +import { fitToolbarActions } from '../lib/selection-toolbar'; // The server refuses keywords per message (a read-only shared folder; // Stalwart 1.0: over 128 bytes, or more than 100 on one message), and the @@ -740,6 +741,9 @@ export default function EmailListScreen({ // Batch (multi-select) sheets. const [batchMoveOpen, setBatchMoveOpen] = React.useState(false); const [tagSheetOpen, setTagSheetOpen] = React.useState(false); + // The selection toolbar's width and its "More" menu (see fitToolbarActions). + const [selectionBarWidth, setSelectionBarWidth] = React.useState(null); + const [selectionMoreOpen, setSelectionMoreOpen] = React.useState(false); // The sheet's changes share one Undo, shown once it closes. const tagUndoGroup = useTagUndoGroup(tagSheetOpen); @@ -1341,6 +1345,69 @@ export default function EmailListScreen({ } }, [mailboxes, currentMailboxId, selectMailbox]); + // The selection toolbar's actions, in bar order. What doesn't fit the + // list column goes into a "More" menu, the least used first. + type SelectionAction = 'star' | 'read' | 'tag' | 'move' | 'spam' | 'archive' | 'delete'; + const selectionActions: Array<{ key: SelectionAction; label: string; icon: React.ReactNode; onPress: () => void }> = [ + { + key: 'star', + label: allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star'), + icon: ( + + ), + onPress: () => reportFailure(handleBulkStar()), + }, + { + key: 'read', + label: allSelectedAreRead + ? t('email_list.batch_actions.mark_unread', 'Mark as unread') + : t('email_list.batch_actions.mark_read', 'Mark as read'), + icon: allSelectedAreRead ? : , + onPress: () => reportFailure(handleBulkMarkReadToggle()), + }, + { + key: 'tag', + label: t('context_menu.tag', 'Tag'), + icon: , + onPress: () => setTagSheetOpen(true), + }, + { + key: 'move', + label: t('email_viewer.move', 'Move'), + icon: , + onPress: () => setBatchMoveOpen(true), + }, + ...(canSpamSelection ? [{ + key: 'spam' as const, + label: inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam'), + icon: inJunk ? : , + onPress: () => reportFailure(handleBulkSpam()), + }] : []), + ...(canArchiveSelection ? [{ + key: 'archive' as const, + label: t('context_menu.archive', 'Archive'), + icon: , + onPress: () => reportFailure(handleBulkArchive()), + }] : []), + { + key: 'delete', + label: t('email_list.batch_actions.delete', 'Delete'), + icon: , + onPress: () => reportFailure(handleBulkDelete()), + }, + ]; + const toolbarFit = fitToolbarActions(selectionActions.map((a) => a.key), { + width: selectionBarWidth, + // Padding (sm each side), the close button and the count (min 24 + xs each side). + reserved: spacing.sm * 2 + componentSizes.buttonLg + 24 + spacing.xs * 2, + button: componentSizes.buttonLg, + overflowOrder: ['spam', 'tag', 'star', 'read', 'move', 'archive', 'delete'], + }); + const renderSearchBar = (inHeader: boolean) => ( + setSelectionBarWidth(e.nativeEvent.layout.width)} + > {selectionCountOnly ? String(selectedIds.size) : selectedLabel} - reportFailure(handleBulkStar())} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={allSelectedAreStarred ? t('context_menu.unstar', 'Unstar') : t('context_menu.star', 'Star')} - > - - - reportFailure(handleBulkMarkReadToggle())} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={allSelectedAreRead - ? t('email_list.batch_actions.mark_unread', 'Mark as unread') - : t('email_list.batch_actions.mark_read', 'Mark as read')} - > - {allSelectedAreRead ? ( - - ) : ( - - )} - - setTagSheetOpen(true)} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={t('context_menu.tag', 'Tag')} - > - - - setBatchMoveOpen(true)} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={t('email_viewer.move', 'Move')} - > - - - {canSpamSelection && ( + {toolbarFit.visible.map((key) => { + const action = selectionActions.find((a) => a.key === key)!; + return ( + + {action.icon} + + ); + })} + {toolbarFit.overflow.length > 0 ? ( reportFailure(handleBulkSpam())} + onPress={() => setSelectionMoreOpen(true)} style={styles.headerButton} hitSlop={6} accessibilityRole="button" - accessibilityLabel={inJunk ? t('context_menu.not_spam', 'Not spam') : t('context_menu.mark_as_spam', 'Report spam')} + accessibilityLabel={t('email_list.batch_actions.more', 'More actions')} > - {inJunk ? ( - - ) : ( - - )} + - )} - {canArchiveSelection && ( - reportFailure(handleBulkArchive())} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={t('context_menu.archive', 'Archive')} - > - - - )} - reportFailure(handleBulkDelete())} - style={styles.headerButton} - hitSlop={6} - accessibilityRole="button" - accessibilityLabel={t('email_list.batch_actions.delete', 'Delete')} - > - - + ) : null} ) : ( @@ -2234,6 +2250,26 @@ export default function EmailListScreen({ }} /> + 0} + title={selectedLabel} + onClose={() => setSelectionMoreOpen(false)} + items={toolbarFit.overflow.map((key) => { + const action = selectionActions.find((a) => a.key === key)!; + return { + key, + label: action.label, + icon: action.icon, + destructive: key === 'delete', + onPress: () => { + setSelectionMoreOpen(false); + // A sheet opened from here waits for this one to go (iOS + // drops a Modal shown while another is dismissing). + setTimeout(action.onPress, Platform.OS === 'ios' ? 350 : 0); + }, + }; + })} + />