diff --git a/app/boot-skeleton.css b/app/boot-skeleton.css index dbf7543cf..dc27f65a3 100644 --- a/app/boot-skeleton.css +++ b/app/boot-skeleton.css @@ -30,6 +30,9 @@ } } +/* Compose heads the folder sidebar, in place of the account switcher. */ +@custom-variant boot-sidebar-compose (&:where([data-boot-sidebar-compose="on"] *)); + /* A message, thread, contact, settings page or admin page is open. */ @custom-variant boot-open (&:where([data-boot-pane] *)); @custom-variant boot-thread (&:where([data-boot-pane="thread"] *)); diff --git a/components/boot-skeleton/mail-skeleton.tsx b/components/boot-skeleton/mail-skeleton.tsx index 07c634645..2a3b4a87d 100644 --- a/components/boot-skeleton/mail-skeleton.tsx +++ b/components/boot-skeleton/mail-skeleton.tsx @@ -131,7 +131,7 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) { {/* A compact desktop folds the sidebar to its icons (sidebarFolded). */} - + @@ -139,9 +139,11 @@ function SidebarSkeleton({ embedded }: { embedded: boolean }) { + {/* Compose in place of the account switcher (sidebarAccountSwitcher off). */} + - {/* The folded sidebar's Compose button. */} -
+ {/* The folded sidebar's Compose button (sidebarAccountSwitcher off). */} +
@@ -665,8 +667,8 @@ export function MailSkeletonBody({ embedded = false }: { embedded?: boolean }) { className={cn( "absolute z-40 shadow-lg bottom-4 end-4 h-12 w-12 max-desk:h-14 max-desk:w-14", "max-desk:mv:w-36 max-desk:mv:rounded-2xl", - // A desktop has Compose in the folder sidebar; a Pro pane keeps the round one. - !embedded && "desk:hidden", + // A desktop can have Compose in the folder sidebar instead; a Pro pane keeps the round one. + !embedded && "desk:mv:hidden desk:boot-sidebar-compose:hidden", )} /> diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx index 366ab7709..a32f33131 100644 --- a/components/email/__tests__/list-row-preview-lines.test.tsx +++ b/components/email/__tests__/list-row-preview-lines.test.tsx @@ -54,38 +54,24 @@ beforeEach(() => { useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' }); }); -// mail-15: the default density puts the preview after the subject, on the -// subject's line, so about ten rows fit at 1366×768 (they were about 96 px, -// seven on screen); comfortable keeps two preview lines, and a phone keeps -// the preview under the subject. +// mail-15: the default density shows one preview line, so more rows fit; +// comfortable keeps two. describe('list row preview lines', () => { - const onSubjectLine = () => { - const preview = screen.getByText(PREVIEW, { exact: false }); - return preview.closest('[data-row-preview]') !== null; - }; + it.each([ + ['regular', 'line-clamp-1'], + ['comfortable', 'line-clamp-2'], + ] as const)('a %s row clamps its preview with %s', (density, clamp) => { + useSettingsStore.setState({ density }); + renderRow([makeEmail()]); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); + }); it.each([ - ['message', [makeEmail()]], - ['conversation', [makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]], - ] as const)('a regular %s row shows its preview on the subject line', (_kind, emails) => { - useSettingsStore.setState({ density: 'regular' }); - renderRow([...emails]); - expect(onSubjectLine()).toBe(true); - expect(document.querySelector('p.line-clamp-1')).toBeNull(); - }); - - it('a comfortable row keeps two preview lines of its own', () => { - useSettingsStore.setState({ density: 'comfortable' }); - renderRow([makeEmail()]); - expect(onSubjectLine()).toBe(false); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-2'); - }); - - it('a regular row on a phone keeps the preview under the subject', () => { - useUIStore.setState({ isMobile: true }); - useSettingsStore.setState({ density: 'regular' }); - renderRow([makeEmail()]); - expect(onSubjectLine()).toBe(false); - expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain('line-clamp-1'); + ['regular', 'line-clamp-1'], + ['comfortable', 'line-clamp-2'], + ] as const)('a %s conversation row clamps its preview with %s', (density, clamp) => { + useSettingsStore.setState({ density }); + renderRow([makeEmail({ id: 'a' }), makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' })]); + expect(screen.getByText(PREVIEW).className.split(/\s+/)).toContain(clamp); }); }); diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx index ed27738ce..98c275ee0 100644 --- a/components/email/__tests__/thread-list-item-columns.test.tsx +++ b/components/email/__tests__/thread-list-item-columns.test.tsx @@ -93,9 +93,7 @@ describe('message list rows under the bottom reading pane (#262)', () => { it('keeps stacked rows in the split layout', () => { useSettingsStore.setState({ mailLayout: 'split' }); const { container } = renderEmails([makeEmail()]); - // Stacked: a preview line of its own, or the preview after the subject - // on the subject's line at the regular density (mail-15). - expect(container.querySelector('p, [data-row-preview]')).not.toBeNull(); + expect(container.querySelector('p')).not.toBeNull(); }); describe('as columns', () => { diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index ec02b40c5..aa32bf841 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -39,7 +39,7 @@ import { useOpenEmailInWindow } from "@/hooks/use-popouts-available"; import { useIsDesktop } from "@/hooks/use-media-query"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { useSettingsStore } from "@/stores/settings-store"; -import { useListRowShape, showsPreviewOnSubjectLine } from "@/hooks/use-list-row-shape"; +import { useListRowShape } from "@/hooks/use-list-row-shape"; import { MailListRowsSkeleton, type MailRowShape } from "@/components/boot-skeleton/mail-skeleton"; import type { Density } from "@/lib/density"; import { useUIStore } from "@/stores/ui-store"; @@ -216,6 +216,7 @@ export function EmailList({ const mountainViewList = useSettingsStore((state) => state.interfaceLayout === 'mountain-view'); const isDesktopPane = useIsDesktop(); const isEmbeddedList = useIsEmbedded(); + const sidebarAccountSwitcher = useSettingsStore((state) => state.sidebarAccountSwitcher); const undo = undoLabels(tNotifications); const tRemove = useTranslations('email_list.confirm_remove'); const { client } = useAuthStore(useShallow((state) => ({ client: state.client }))); @@ -492,11 +493,7 @@ export function EmailList({ // up after a jump down (scrollbar drag, End). So guess per row. const latest = item?.kind === 'row' ? item.thread.latestEmail : undefined; let size = { 'extra-compact': 32, compact: 60, regular: 84, comfortable: 104 }[density]; - if (showsPreviewOnSubjectLine({ showPreview, density, isMobile, mountainView: mountainViewList })) { - // The preview follows the subject on its line, and the subject line - // drops its margin (mail-15). - size -= 4; - } else if (showPreview && density !== 'extra-compact') { + if (showPreview && density !== 'extra-compact') { // A mail without a preview draws a one-line "No preview available", // a line (23px) shorter than a real one. Only comfortable rows show a // second preview line (mail-15). @@ -507,7 +504,7 @@ export function EmailList({ const hasCode = !!latest && showVerificationCodes && !!listVerificationCode(latest); if (latest && (hasCode || (onOpenAttachment && listRowShowsChips(latest, loadAttachments)))) size += 28; return size; - }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments, isMobile, mountainViewList]); + }, [density, isFocusedMailLayout, showPreview, showVerificationCodes, items, onOpenAttachment, loadAttachments]); // Stable per list, so the virtualizer does not rebuild every row's // measurement on each scroll render. @@ -1165,10 +1162,11 @@ export function EmailList({ // Room under the last row for the round Compose button, which would otherwise // sit on that row's actions (Mountain View has no such button, and a desktop - // has Compose in the folder sidebar instead). A spacer and - // not padding on the list: padding is a minimum height the list can't go - // under, and on a short screen it pushed the rows over the tab bar. - const composeButtonRoom = !mountainViewList && (!isDesktopPane || isEmbeddedList) ? -
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -988,16 +987,11 @@ const SingleEmailItem = React.forwardRef( : "font-normal text-foreground/90" )}> {subjectSnippet ? : (email.subject || tNoSubject)} - {previewOnSubjectLine && ( - - {' – '}{previewSnippet ? : (trimmedPreview || t('no_preview_available'))} - - )}
- {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && density !== 'extra-compact' && density !== 'compact' && (

-
+
{tagPlacement === 'subject' && tagIds.length > 0 && ( {tagIds.map((id) => ( @@ -1782,16 +1775,11 @@ export const ThreadListItem = React.forwardRef {subjectSnippet ? : (latestEmail.subject || tNoSubject)} - {previewOnSubjectLine && ( - - {' – '}{previewSnippet ? : (trimmedPreview || tEmailViewer('no_preview_available'))} - - )}
- {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && ( + {showPreview && density !== 'extra-compact' && density !== 'compact' && (

({ PluginSlot: () => null })); vi.mock('@/components/tour/tour-provider', () => ({ @@ -42,9 +44,36 @@ function narrowWindow(narrow: boolean) { })) as typeof window.matchMedia; } -describe('folder sidebar header', () => { +describe('folder sidebar header, by default', () => { beforeEach(() => { - useSettingsStore.setState({ interfaceLayout: 'default', enableUnifiedMailbox: false } as never); + useSettingsStore.setState({ + interfaceLayout: 'default', + enableUnifiedMailbox: false, + sidebarAccountSwitcher: useSettingsStore.getInitialState().sidebarAccountSwitcher, + } as never); + useUIStore.setState({ sidebarCollapsed: false } as never); + narrowWindow(false); + }); + afterEach(() => { + window.matchMedia = realMatchMedia; + }); + + it('starts with the account switcher and leaves Compose to the round button', () => { + render(); + expect(screen.getByTestId('account-switcher')).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'compose' })).toBeNull(); + }); + + it('has no Compose icon when the sidebar is collapsed', () => { + useUIStore.setState({ sidebarCollapsed: true } as never); + render(); + expect(screen.queryByRole('button', { name: 'compose' })).toBeNull(); + }); +}); + +describe('folder sidebar header, with Compose at the top', () => { + beforeEach(() => { + useSettingsStore.setState({ interfaceLayout: 'default', enableUnifiedMailbox: false, sidebarAccountSwitcher: false } as never); useUIStore.setState({ sidebarCollapsed: false } as never); narrowWindow(false); }); diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b07adb2d4..9cceb3adc 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -85,6 +85,7 @@ import { trashAndJunkIds } from "@/lib/unified-mailbox"; import { toast } from "@/stores/toast-store"; import { toastWithUndo, undoLabels, undoMoveAction } from "@/lib/email-action-toast"; import { debug } from "@/lib/debug"; +import { AccountSwitcher } from "./account-switcher"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } from "@/lib/deep-links"; import { useTour } from "@/components/tour/tour-provider"; @@ -1292,6 +1293,7 @@ function SidebarImpl({ const selectedKeywordAccountId = useEmailStore(s => s.selectedKeywordAccountId); const personalSelectedKeyword = selectedKeywordAccountId ? null : selectedKeyword; const isEmbedded = useIsEmbedded(); + const sidebarAccountSwitcher = useSettingsStore(s => s.sidebarAccountSwitcher); const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified); const showSharedMailAccounts = useSettingsStore(s => s.showSharedMailAccounts); @@ -2047,12 +2049,16 @@ function SidebarImpl({ {isCollapsed ? : } - {/* Compose at the head of the folders on a desktop, as in Gmail - and Outlook; the round button over the list stays for phones - and tablets, where this pane is a drawer. The account switcher - that sat here was a second one beside the rail's avatar, which - every app shows (usability test 2026-11-04, mail-13, mail-27). */} - {!isCollapsed && !isDrawer && onCompose && ( + {/* The account switcher heads the folders by default, with the + round Compose button over the list. With the setting off a + desktop puts Compose here instead, as in Gmail and Outlook, + and the rail's avatar is the one account switcher (usability + test 2026-11-04, mail-13, mail-27); phones and tablets keep + the round button, since this pane is a drawer there. */} + {!isCollapsed && sidebarAccountSwitcher && ( + + )} + {!isCollapsed && !sidebarAccountSwitcher && !isDrawer && onCompose && (

- {isCollapsed && !isDrawer && onCompose && ( + {isCollapsed && !sidebarAccountSwitcher && !isDrawer && onCompose && (
({ ), })); -// On a desktop Compose sits at the head of the folder sidebar (mail-13). -vi.mock('@/components/layout/sidebar', () => ({ - Sidebar: ({ onCompose }: { onCompose?: () => void }) => , -})); +// With sidebarAccountSwitcher off, Compose sits at the head of the folder +// sidebar on a desktop (mail-13); by default it is the round button. +vi.mock('@/components/layout/sidebar', async () => { + const { useSettingsStore: settings } = await import('@/stores/settings-store'); + return { + Sidebar: ({ onCompose }: { onCompose?: () => void }) => + settings((s) => s.sidebarAccountSwitcher) ? null : , + }; +}); vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); diff --git a/components/mail/__tests__/mobile-tour-end.test.tsx b/components/mail/__tests__/mobile-tour-end.test.tsx index 99ee37297..5c91af470 100644 --- a/components/mail/__tests__/mobile-tour-end.test.tsx +++ b/components/mail/__tests__/mobile-tour-end.test.tsx @@ -29,10 +29,17 @@ vi.mock('@/components/email/email-composer', () => ({ return

stub composer

; }, })); -// On a desktop Compose sits at the head of the folder sidebar (mail-13). -vi.mock('@/components/layout/sidebar', () => ({ - Sidebar: ({ onCompose }: { onCompose?: () => void }) => (device.isMobile ? null : ), -})); +// With sidebarAccountSwitcher off, Compose sits at the head of the folder +// sidebar on a desktop (mail-13); by default it is the round button. +vi.mock('@/components/layout/sidebar', async () => { + const { useSettingsStore: settings } = await import('@/stores/settings-store'); + return { + Sidebar: ({ onCompose }: { onCompose?: () => void }) => { + const switcherOnTop = settings((s) => s.sidebarAccountSwitcher); + return device.isMobile || switcherOnTop ? null : ; + }, + }; +}); vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null })); vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null })); vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null })); @@ -95,7 +102,7 @@ beforeEach(() => { getClientForAccount: () => client as never, getAllConnectedClients: () => new Map([['login', client]]) as never, }); - useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never); + useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0, sidebarAccountSwitcher: true } as never); sendEmail.mockReset().mockResolvedValue({}); useUIStore.setState({ activeView: 'list', sidebarOpen: false }); useEmailStore.setState({ @@ -128,14 +135,19 @@ describe('Mail after the tour on a phone', () => { }); // Usability test 2026-11-04, mail-13: on a desktop the round button sat on - // the list's rows; Compose is at the head of the folder sidebar there. - it('draws the round Compose button on a phone, not on a desktop', () => { + // the list's rows. It stays by default, with the account switcher at the + // head of the folder sidebar; turned off, Compose is there instead. + it('draws the round Compose button on a phone, and on a desktop unless Compose is in the sidebar', () => { const view = render(); expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1); device.isMobile = false; view.rerender(); + expect(document.querySelectorAll('[data-tour="compose-button"]')).toHaveLength(1); + + act(() => { useSettingsStore.setState({ sidebarAccountSwitcher: false } as never); }); expect(document.querySelector('[data-tour="compose-button"]')).toBeNull(); + expect(screen.getByRole('button', { name: 'sidebar.compose' })).toBeTruthy(); }); // mail-27 kept the tablet from drawing the header's ⋮ and the list's ⋮ one diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index 7833a66e2..f1192cee7 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -493,6 +493,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { // Desktop lifts search into a global header. Mobile keeps its native // one-pane flow, with Mountain View's own app chrome. const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isEmbedded; + const sidebarAccountSwitcher = useSettingsStore((state) => state.sidebarAccountSwitcher); const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded; // Mountain View reads a thread as one conversation in the viewer, on the // phone as on the desktop, so both show the same order and details. (#1224) @@ -6329,10 +6330,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
- {/* Floating Compose Button - on desktop the folder sidebar has - Compose at its top instead (and Mountain View its rail), so the - button no longer sits on the list's rows. */} - {!mvShell && (isMobile || isEmbedded) &&