From d632956231b83d05b4332ab27db0157d33b9ce00 Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Mon, 5 Oct 2026 10:36:20 +0200 Subject: [PATCH] feat: add Mountain View, an optional alternative layout Add an "Interface layout" setting at the top of Settings > Layout with two options, Default and Mountain View. Default is the existing layout and stays the default, so nothing changes unless a user opts in. Mountain View rearranges the chrome while keeping the theme's colours, icons and type: - desktop: a top bar with a wide search field (filters drop from the field), an apps grid and the account avatar instead of the left app rail; flat navigation with Starred under Inbox and the rest behind "More"; a Compose pill; one list toolbar with select-all, refresh, overflow menu, a 1-N of total range and the batch actions; rows led by a checkbox and a star; an icon-only message toolbar with Reply / Reply all / Forward under the message; compose in a docked window; an account popover with per-account unread counts; j/k cursor, x to select and g-sequences - phone: rounded search header, compact rows with the star on the preview line, and Reply / Reply all / Forward at the end of a message instead of the bottom action bar It is one setting (`interfaceLayout`), written to a data attribute on that keys a block of CSS tokens in globals.css, plus a few components gated on the flag. The mail layout in force is derived (effectiveMailLayout), so the user's saved mail layout is never rewritten. The setting is listed in the admin policy tab, and the new strings are translated in all locales. Includes unit tests and a Playwright smoke test against a running instance (scripts/mountain-view-smoke/). --- app/(main)/admin/_tabs/policy.tsx | 1 + app/globals.css | 107 ++++ .../email/__tests__/mv-list-toolbar.test.tsx | 158 ++++++ .../email/__tests__/thread-list-item.test.tsx | 58 +- components/email/email-list.tsx | 11 +- components/email/email-viewer.tsx | 101 +++- components/email/mv-compose-window.tsx | 76 +++ components/email/mv-list-toolbar.tsx | 529 ++++++++++++++++++ components/email/thread-list-item.tsx | 260 +++++++-- components/icons/index.ts | 1 + components/keyboard-shortcuts-modal.tsx | 17 +- .../account-switcher-mountain-view.test.tsx | 121 ++++ .../sidebar-mountain-view-flat-list.test.tsx | 136 +++++ components/layout/account-switcher.tsx | 152 ++++- components/layout/mobile-header.tsx | 27 +- components/layout/mv-top-bar.tsx | 335 +++++++++++ components/layout/navigation-rail.tsx | 18 +- components/layout/sidebar.tsx | 140 ++++- components/mail/mail-app.tsx | 347 ++++++++---- .../__tests__/filter-panel-host.test.tsx | 55 ++ components/search/filter-panel-host.tsx | 83 +++ components/settings/layout-settings.tsx | 18 +- components/settings/reading-settings.tsx | 4 +- hooks/use-effective-mail-layout.ts | 8 + hooks/use-keyboard-shortcuts.ts | 84 ++- hooks/use-pro-multi-account-mailboxes.ts | 11 +- lib/__tests__/account-inbox-unread.test.ts | 64 +++ lib/account-inbox-unread.ts | 35 ++ locales/ar/common.json | 18 +- locales/ca/common.json | 18 +- locales/cs/common.json | 18 +- locales/da/common.json | 18 +- locales/de/common.json | 18 +- locales/en/common.json | 18 +- locales/es/common.json | 18 +- locales/fa/common.json | 18 +- locales/fr/common.json | 18 +- locales/he/common.json | 18 +- locales/hu/common.json | 18 +- locales/it/common.json | 18 +- locales/ja/common.json | 18 +- locales/ko/common.json | 18 +- locales/lv/common.json | 18 +- locales/mn/common.json | 18 +- locales/nb/common.json | 18 +- locales/nl/common.json | 18 +- locales/pl/common.json | 18 +- locales/pt/common.json | 18 +- locales/ro/common.json | 18 +- locales/ru/common.json | 18 +- locales/sk/common.json | 18 +- locales/tr/common.json | 18 +- locales/uk/common.json | 18 +- locales/zh-TW/common.json | 18 +- locales/zh/common.json | 18 +- .../mountain-view-smoke.mjs | 131 +++++ .../settings-density-mountain-view.test.ts | 43 ++ stores/settings-store.ts | 160 ++++-- 58 files changed, 3477 insertions(+), 300 deletions(-) create mode 100644 components/email/__tests__/mv-list-toolbar.test.tsx create mode 100644 components/email/mv-compose-window.tsx create mode 100644 components/email/mv-list-toolbar.tsx create mode 100644 components/layout/__tests__/account-switcher-mountain-view.test.tsx create mode 100644 components/layout/__tests__/sidebar-mountain-view-flat-list.test.tsx create mode 100644 components/layout/mv-top-bar.tsx create mode 100644 components/search/__tests__/filter-panel-host.test.tsx create mode 100644 components/search/filter-panel-host.tsx create mode 100644 hooks/use-effective-mail-layout.ts create mode 100644 lib/__tests__/account-inbox-unread.test.ts create mode 100644 lib/account-inbox-unread.ts create mode 100644 scripts/mountain-view-smoke/mountain-view-smoke.mjs create mode 100644 stores/__tests__/settings-density-mountain-view.test.ts diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 29c1a6a56..b7aeb7c7b 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -42,6 +42,7 @@ const FEATURE_GATE_LABELS: Partial by the settings store + (`applyInterfaceLayout`). The default layout declares no overrides, so + everything below is additive and switched off by a single attribute. + Mountain View sets its own geometry - bar heights, rail widths, pill shapes, + row rhythm - while colour, iconography and type keep coming from the active + theme. + ========================================================================== */ + +[data-interface-layout="mountain-view"] { + --mv-topbar-h: 64px; + --mv-rail-w: 72px; + --mv-rail-peek-w: 256px; + --mv-nav-row-h: 32px; + --mv-nav-radius: 9999px; + --mv-compose-h: 56px; + --mv-search-h: 48px; + --mv-search-radius: 8px; + --mv-popover-w: 352px; + --mv-popover-radius: 28px; +} + +/* Search: a filled 48px field that lifts onto a card while focused, rather + than the outlined input the default layout uses. */ +[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] { + height: var(--mv-search-h); + border-radius: var(--mv-search-radius); + border-color: transparent; + background-color: var(--color-muted); + padding-inline-start: 3rem; + font-size: 1rem; +} + +[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:hover { + border-color: transparent; + background-color: var(--color-accent); +} + +[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input]:focus-visible { + background-color: var(--color-background); + border-color: transparent; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.16), 0 4px 8px 3px rgb(0 0 0 / 0.08); +} + +/* The filter button sits inside the field's trailing end: keep typed text and + the clear button clear of it. */ +[data-interface-layout="mountain-view"] .mv-topbar input[data-search-input] { + padding-inline-end: 5.5rem; +} + +[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > button { + inset-inline-end: 3.25rem; +} + +/* The magnifier sits on the field's baseline grid, one 48px hit area in. */ +[data-interface-layout="mountain-view"] .mv-topbar-search > div > form > svg { + inset-inline-start: 1rem; + width: 1.25rem; + height: 1.25rem; +} + +/* Folder rows: 32px pills flush with the rail's leading edge and rounded only + on the trailing side, so the selected label reads as one continuous shape + with the rail. The default layout's leading accent stripe goes away with it. */ +[data-interface-layout="mountain-view"] [data-mv-nav-row] { + min-height: var(--mv-nav-row-h); + margin-inline-end: 12px; + padding-inline-end: 16px; + border-inline-start-width: 0; + border-start-start-radius: 0; + border-end-start-radius: 0; + border-start-end-radius: var(--mv-nav-radius); + border-end-end-radius: var(--mv-nav-radius); +} + +/* Compose: a 56px pill, which shrinks to a circle with the rail. */ +[data-interface-layout="mountain-view"] [data-mv-compose] { + height: var(--mv-compose-h); + border-radius: var(--mv-nav-radius); +} + +/* The account popover: a wide card with a very round corner, rather + than the narrow menu the rail and the sidebar switchers drop. */ +[data-interface-layout="mountain-view"] [data-mv-account-popover] { + width: var(--mv-popover-w); + border-radius: var(--mv-popover-radius); +} + +/* The advanced filters drop from the search field with a small gap and take + its corner, so the two read as one control. Position and width come from + the field's own box, measured by FilterPanelHost. */ +[data-interface-layout="mountain-view"] [data-mv-filter-panel] { + border-radius: var(--mv-search-radius); +} + +/* The collapsed rail's hover peek. Only on desktop: below that breakpoint the + sidebar is already a drawer, opened by a tap rather than by a pointer that + does not exist. The width beats Tailwind's own `lg:w-full` on specificity, + which is what keeps the floating panel from filling the 72px column. */ +@media (min-width: 1024px) { + [data-interface-layout="mountain-view"] [data-mv-rail-peek] { + width: var(--mv-rail-peek-w); + } +} diff --git a/components/email/__tests__/mv-list-toolbar.test.tsx b/components/email/__tests__/mv-list-toolbar.test.tsx new file mode 100644 index 000000000..0d9fd71ea --- /dev/null +++ b/components/email/__tests__/mv-list-toolbar.test.tsx @@ -0,0 +1,158 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import { KEYWORD_PREFIX } from '@/lib/thread-utils'; + +// "Move to" and "Labels" were reachable only from a row's context menu, so a +// selection could be archived or deleted from the toolbar but not filed. Both +// now sit in the batch half of the Mountain View list toolbar. + +const { MvListToolbar } = await import('../mv-list-toolbar'); +const { useEmailStore } = await import('@/stores/email-store'); +const { useAuthStore } = await import('@/stores/auth-store'); +const { useSettingsStore } = await import('@/stores/settings-store'); + +const rights = { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, + maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, +}; + +const mailbox = (id: string, name: string, role?: string): Mailbox => + ({ id, name, role, sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, + unreadThreads: 0, isSubscribed: true, myRights: rights }) as Mailbox; + +const message = (id: string, keywords: Record = {}): Email => + ({ id, threadId: id, keywords, mailboxIds: { inbox: true } }) as unknown as Email; + +const mailboxes = [mailbox('inbox', 'Inbox', 'inbox'), mailbox('receipts', 'Receipts')]; + +describe('MvListToolbar filing actions', () => { + const batchMoveToMailbox = vi.fn().mockResolvedValue(undefined); + const batchSetTag = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + batchMoveToMailbox.mockClear(); + batchSetTag.mockClear(); + const emails = [message('a', { [KEYWORD_PREFIX + 'work']: true }), message('b')]; + useAuthStore.setState({ client: {} as never } as never); + useSettingsStore.setState({ + emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }], + nestedTags: false, + } as never); + useEmailStore.setState({ + emails, + mailboxes, + selectedMailbox: 'inbox', + selectedEmailIds: new Set(emails.map((email) => email.id)), + isUnifiedView: false, + unifiedRole: null, + batchMoveToMailbox, + batchSetTag, + } as never); + }); + + const toolbar = () => + render( {}} />); + + it('files the selection into a folder that will take it', async () => { + toolbar(); + + fireEvent.click(screen.getByLabelText('move_to')); + fireEvent.click(screen.getByText('Receipts')); + + await waitFor(() => expect(batchMoveToMailbox).toHaveBeenCalledWith({}, 'receipts')); + }); + + it('does not offer the folder you are already in as a destination', () => { + toolbar(); + + fireEvent.click(screen.getByLabelText('move_to')); + expect(screen.queryByText('mailboxes.inbox')).toBeNull(); + }); + + it('puts a tag on the whole selection when only some of it carries the tag', async () => { + toolbar(); + + fireEvent.click(screen.getByLabelText('tag')); + fireEvent.click(screen.getByText('Work')); + + await waitFor(() => expect(batchSetTag).toHaveBeenCalledTimes(1)); + expect(batchSetTag).toHaveBeenCalledWith({}, 'work', true); + }); + + it('takes a tag off once the whole selection carries it', async () => { + const emails = [ + message('a', { [KEYWORD_PREFIX + 'work']: true }), + message('b', { [KEYWORD_PREFIX + 'work']: true }), + ]; + useEmailStore.setState({ + emails, + selectedEmailIds: new Set(emails.map((email) => email.id)), + } as never); + toolbar(); + + fireEvent.click(screen.getByLabelText('tag')); + fireEvent.click(screen.getByText('Work')); + + await waitFor(() => expect(batchSetTag).toHaveBeenCalledTimes(1)); + expect(batchSetTag).toHaveBeenCalledWith({}, 'work', false); + }); + + it('shows neither control until something is selected', () => { + useEmailStore.setState({ selectedEmailIds: new Set() } as never); + toolbar(); + + expect(screen.queryByLabelText('move_to')).toBeNull(); + expect(screen.queryByLabelText('tag')).toBeNull(); + }); +}); + +describe('MvListToolbar overflow menu', () => { + beforeEach(() => { + useAuthStore.setState({ client: {} as never } as never); + useEmailStore.setState({ + emails: [message('a')], + mailboxes, + selectedMailbox: 'inbox', + selectedEmailIds: new Set(), + isUnifiedView: false, + unifiedRole: null, + } as never); + }); + + const open = () => fireEvent.click(screen.getAllByLabelText('mark_folder_read')[0]); + + it('offers marking every folder read, not just this one', () => { + render( + {}} + onMarkFolderRead={() => {}} + onMarkAllFoldersRead={() => {}} + /> + ); + open(); + + expect(screen.getByText('mark_all_folders_read')).toBeInTheDocument(); + }); + + it('withholds emptying the folder outside spam and the bin', () => { + render( {}} onEmptyFolder={() => {}} />); + open(); + + expect(screen.queryByText('empty_folder')).toBeNull(); + }); + + it('offers it in the bin', () => { + useEmailStore.setState({ + mailboxes: [mailbox('trash', 'Trash', 'trash')], + selectedMailbox: 'trash', + } as never); + const onEmptyFolder = vi.fn(); + render( {}} onEmptyFolder={onEmptyFolder} />); + open(); + + fireEvent.click(screen.getByText('empty_folder')); + expect(onEmptyFolder).toHaveBeenCalledOnce(); + }); +}); diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx index ae627403d..17ec36186 100644 --- a/components/email/__tests__/thread-list-item.test.tsx +++ b/components/email/__tests__/thread-list-item.test.tsx @@ -1,8 +1,9 @@ -import { render, screen, act } from '@testing-library/react'; +import { render, screen, act, fireEvent } from '@testing-library/react'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { ThreadListItem } from '../thread-list-item'; import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store'; import { useEmailStore } from '@/stores/email-store'; +import { useUIStore } from '@/stores/ui-store'; import { groupEmailsByThread } from '@/lib/thread-utils'; import type { Email } from '@/lib/jmap/types'; @@ -60,12 +61,17 @@ function renderRow(email: Email) { ); } +beforeEach(() => { + useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true }); +}); + describe('ThreadListItem tag badge', () => { beforeEach(() => { useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', }); useEmailStore.setState({ selectedEmailIds: new Set(), @@ -139,6 +145,7 @@ describe('ThreadListItem multi-message thread', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', }); useEmailStore.setState({ selectedEmailIds: new Set(), @@ -200,6 +207,7 @@ describe('ThreadListItem row content', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', }); useEmailStore.setState({ selectedEmailIds: new Set(), @@ -223,6 +231,51 @@ describe('ThreadListItem row content', () => { // rather than getting a paragraph of its own. expect(container.querySelector('p')).toBeNull(); }); + + it('puts the star at the trailing edge in the Mountain View phone row', () => { + useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split' }); + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + const onToggleStar = vi.fn(); + const [thread] = groupEmailsByThread([makeEmail()]); + const { container } = render( + {}} + onEmailSelect={() => {}} + onToggleStar={onToggleStar} + />, + ); + + const star = container.querySelector('[data-mv-mobile-row] button[aria-pressed]'); + expect(star).not.toBeNull(); + fireEvent.click(star!); + + expect(onToggleStar).toHaveBeenCalledTimes(1); + expect(container.querySelector('[data-mv-mobile-row]')).toBeInTheDocument(); + }); +}); + +describe('ThreadListItem Mountain View phone row layout', () => { + it('sets the star at the end of the preview line, not under the time', () => { + useSettingsStore.setState({ interfaceLayout: 'mountain-view', mailLayout: 'split', showPreview: true, density: 'regular' } as never); + useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false }); + const [thread] = groupEmailsByThread([makeEmail()]); + const { container } = render( + {}} + onEmailSelect={() => {}} + onToggleStar={() => {}} + />, + ); + + const stars = container.querySelectorAll('[data-mv-mobile-row] button[aria-pressed]'); + expect(stars).toHaveLength(1); + // Same line as the preview text: snippet ... star. + expect(stars[0].parentElement?.querySelector('p')).not.toBeNull(); + }); }); describe('ThreadListItem shift-range avatar selection', () => { @@ -231,6 +284,7 @@ describe('ThreadListItem shift-range avatar selection', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', }); }); @@ -268,6 +322,7 @@ describe('ThreadListItem row tint', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', tintListRowsByTag: true, }); useEmailStore.setState({ @@ -311,6 +366,7 @@ describe('ThreadListItem account row tint', () => { emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', + interfaceLayout: 'default', tintListRowsByTag: true, tintListRowsByAccount: false, }); diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 0701150fd..c4bb0080f 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -17,6 +17,7 @@ import { useEmailStore, ArchiveMailboxNotFoundError } from "@/stores/email-store import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useSettingsStore } from "@/stores/settings-store"; +import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { useUIStore } from "@/stores/ui-store"; import { useMessageListTabsStore } from "@/stores/message-list-tabs-store"; import { groupEmailsByThread, sortThreadGroups, threadKeyFor, getEmailTagIds } from "@/lib/thread-utils"; @@ -62,6 +63,11 @@ interface EmailListProps { onLoadMoreScheduled?: () => void; onCancelScheduledForEdit?: (email: Email) => void | Promise; onRescheduleScheduled?: (email: Email) => void | Promise; + /** + * Mountain View folds the batch verbs into the single toolbar above the + * list, so the separate bar that would otherwise slide in here is redundant. + */ + hideBatchToolbar?: boolean; } export function EmailList({ @@ -91,6 +97,7 @@ export function EmailList({ onMoveToMailbox, onEditDraft, isScheduledView = false, + hideBatchToolbar = false, onLoadMoreScheduled, onCancelScheduledForEdit, onRescheduleScheduled, @@ -259,7 +266,7 @@ export function EmailList({ const density = useSettingsStore((state) => state.density); const showPreview = useSettingsStore((state) => state.showPreview); const showVerificationCodes = useSettingsStore((state) => state.showVerificationCodes); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const mailLayout = useEffectiveMailLayout(); const footerHasMore = hasMore ?? hasMoreEmails; const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore; const isMobile = useUIStore((state) => state.isMobile); @@ -529,7 +536,7 @@ export function EmailList({ ref={batchToolbarRef} className={cn( "transition-all duration-300 ease-in-out overflow-hidden", - hasSelection && !isScheduledView ? "max-h-16 opacity-100" : "max-h-0 opacity-0" + hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0" )} >
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index cdf91d2d0..a3430a995 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -14,7 +14,7 @@ import { buildContactsPath, buildMailPath } from "@/lib/deep-links"; import { useCopyLink } from "@/hooks/use-copy-link"; import { Button } from "@/components/ui/button"; import { Avatar } from "@/components/ui/avatar"; -import { formatFileSize, cn, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils"; +import { formatFileSize, cn, MailboxNode, formatDate, formatDateTime, generateUUID } from "@/lib/utils"; import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; import { emailDisplayDate } from "@/lib/email-date"; import { TagBadge } from "./tag-badge"; @@ -89,6 +89,7 @@ import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import type { Attachment as PostalMimeAttachment } from 'postal-mime'; import { useSettingsStore } from "@/stores/settings-store"; +import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { useUIStore } from "@/stores/ui-store"; import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store"; import { toast } from "@/stores/toast-store"; @@ -705,8 +706,9 @@ export function EmailViewer({ const emailKeywords = useSettingsStore((state) => state.emailKeywords); const { sortTagIds, tagColor } = useKeywordFormat(); const toolbarPosition = useSettingsStore((state) => state.toolbarPosition); - const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels); + const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); + const mailLayout = useEffectiveMailLayout(); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); @@ -758,6 +760,15 @@ export function EmailViewer({ // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); + // Mountain View's conversation view: an icon-only toolbar at the top, and Reply / + // Reply all / Forward as buttons under the message rather than in the bar. + const mvViewer = interfaceLayout === 'mountain-view' && !isMobile; + // On the phone too Mountain View ends the message with Reply / Reply all / Forward + // and has no bar fixed at the bottom; previous / next move to the top bar. + const mvMobileViewer = interfaceLayout === 'mountain-view' && isMobile; + const mvReplyRow = mvViewer || mvMobileViewer; + // Mountain View's toolbars are icon-only, on the phone as on the desktop. + const showToolbarLabels = showToolbarLabelsSetting && interfaceLayout !== 'mountain-view'; // Inside a Pro pane, `isMobile` above is pane-width based: overlays that // would go viewport-fixed must instead cover just the pane (via // PaneOverlay + absolute positioning), and viewport CSS breakpoints like @@ -3040,6 +3051,32 @@ export function EmailViewer({ )} + {showBackButton && mvMobileViewer && ( + <> + + + + )} {isScheduled && canCancelScheduled && ( <> )} - {!isScheduled && !isDraft && (<> + {!isScheduled && !isDraft && !mvViewer && (<> {moreMenuOpen && !isMobile && (
)}
- {/* Date/time on the right of subject row - hidden on mobile, shown next to sender */} -
+ {/* Date/time on the right of subject row - hidden on mobile, shown next to sender. + Mountain View puts it on the sender's row instead, and shows no size. */} +
{formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} @@ -4012,7 +4050,7 @@ export function EmailViewer({ )} {/* Email Content Area */} -
+
{/* === SENDER INFO (Desktop) === */} @@ -4047,6 +4085,9 @@ export function EmailViewer({ ) : ( {t('unknown_sender')} )} + {mvViewer && sender?.email && sender?.name && ( + <{sender.email}> + )} {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( )} + {mvViewer && ( + + {formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + + )}
- {/* Email address under name */} - {sender?.email && sender?.name && ( + {/* Email address under name (on the name's line under Mountain View) */} + {!mvViewer && sender?.email && sender?.name && (
{sender.email}
)}
@@ -4336,8 +4382,8 @@ export function EmailViewer({ /> )}
- {/* Email address under name */} - {sender?.email && sender?.name && ( + {/* Email address under name - Mountain View keeps it in the details */} + {!mvMobileViewer && sender?.email && sender?.name && (
{sender.email}
)} {/* Row 2: Recipients */} @@ -4379,9 +4425,11 @@ export function EmailViewer({ {/* Date/time + size on the right (mobile) */}
- {formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} + {mvMobileViewer + ? formatDate(emailDisplayDate(email)) + : formatDateTime(emailDisplayDate(email), timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' })} - {email.size > 0 && ( + {email.size > 0 && !mvMobileViewer && (
{formatFileSize(email.size)}
@@ -5322,8 +5370,29 @@ export function EmailViewer({ + {/* Mountain View: Reply / Reply all / Forward as buttons under the message. */} + {mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && ( +
+ + + +
+ )} + {/* Quick Reply Section - hidden for drafts and while loading a new email */} - {!isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
+ {!mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && (effectiveEmailContent.isHtml ? iframeReady : true) && (
("normal"); + const minimized = state === "minimized"; + const maximized = state === "maximized"; + + return ( + <> + {maximized && ( +
setState("normal")} + /> + )} +
+
+ + +
+ {/* Folded down, the title bar is the whole window and opens it again. */} + {minimized && ( +
+ + ); +} diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx new file mode 100644 index 000000000..3f320d975 --- /dev/null +++ b/components/email/mv-list-toolbar.tsx @@ -0,0 +1,529 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + Archive, + CheckSquare, + Folder, + FolderInput, + Loader2, + Mail, + MailOpen, + MoreVertical, + RotateCcw, + ShieldAlert, + ShieldCheck, + Square, + Tag, + Trash2, +} from "@/components/icons"; +import { useTranslations } from "next-intl"; +import { useAuthStore } from "@/stores/auth-store"; +import { useEmailStore } from "@/stores/email-store"; +import { useConfirmDialog } from "@/hooks/use-confirm-dialog"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; +import { TagPicker } from "@/components/email/tag-picker"; +import { localizeMailboxName } from "@/lib/mailbox-label"; +import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets"; +import { getEmailTagIds } from "@/lib/thread-utils"; +import { cn, type MailboxNode } from "@/lib/utils"; + +interface MvListToolbarProps { + /** Loaded conversation count, for the right-hand range readout. */ + loadedCount: number; + /** Total in the folder, when the server has told us. */ + totalCount?: number; + onRefresh: () => void; + isRefreshing?: boolean; + onMarkFolderRead?: () => void; + onMarkAllFoldersRead?: () => void; + /** Only offered where emptying is the usual thing to do: spam and the bin. */ + onEmptyFolder?: () => void; + /** Scheduled view: only the per-message scheduling actions make sense. */ + disabled?: boolean; +} + +function ToolbarButton({ + icon: Icon, + label, + onClick, + disabled, + busy, + className, +}: { + icon: typeof Archive; + label: string; + onClick: () => void; + disabled?: boolean; + busy?: boolean; + className?: string; +}) { + return ( + + ); +} + +/** + * Mountain View's single list toolbar: one row above the list that morphs. + * With nothing selected it offers select-all, refresh and an overflow menu; + * as soon as a conversation is ticked the batch verbs appear in the same row, + * rather than in a second bar that pushes the list down. + */ +export function MvListToolbar({ + loadedCount, + totalCount, + onRefresh, + isRefreshing = false, + onMarkFolderRead, + onMarkAllFoldersRead, + onEmptyFolder, + disabled = false, +}: MvListToolbarProps) { + const t = useTranslations("email_list"); + const tBatch = useTranslations("email_list.batch_actions"); + const tActions = useTranslations("settings.email_behavior.hover_actions"); + const tFolder = useTranslations("mailbox_context_menu"); + const tMenu = useTranslations("context_menu"); + const tSidebar = useTranslations("sidebar"); + + const client = useAuthStore((s) => s.client); + const { + emails, + mailboxes, + selectedMailbox, + selectedEmailIds, + selectAllEmails, + clearSelection, + batchArchive, + batchDelete, + batchMarkAsRead, + batchMarkAsSpam, + batchUndoSpam, + batchMoveToMailbox, + batchSetTag, + isUnifiedView, + unifiedRole, + } = useEmailStore(); + + const [isProcessing, setIsProcessing] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); + const menuButtonRef = useRef(null); + const closeMenu = useCallback(() => setMenuOpen(false), []); + const { menuRef, onKeyDown: onMenuKeyDown } = useMenuNavigation({ + open: menuOpen, + onClose: closeMenu, + triggerRef: menuButtonRef, + }); + const [moveOpen, setMoveOpen] = useState(false); + const moveButtonRef = useRef(null); + const closeMove = useCallback(() => setMoveOpen(false), []); + const { menuRef: moveRef, onKeyDown: onMoveKeyDown } = useMenuNavigation({ + open: moveOpen, + onClose: closeMove, + triggerRef: moveButtonRef, + }); + + const [tagOpen, setTagOpen] = useState(false); + const tagButtonRef = useRef(null); + const closeTag = useCallback(() => setTagOpen(false), []); + const { menuRef: tagRef, onKeyDown: onTagKeyDown } = useMenuNavigation({ + open: tagOpen, + onClose: closeTag, + triggerRef: tagButtonRef, + }); + + const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); + + useEffect(() => { + if (!menuOpen) return; + const onPointerDown = (e: MouseEvent) => { + if ( + menuButtonRef.current?.contains(e.target as Node) || + menuRef.current?.contains(e.target as Node) + ) return; + setMenuOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [menuOpen, menuRef]); + + useEffect(() => { + if (!moveOpen && !tagOpen) return; + const onPointerDown = (e: MouseEvent) => { + const inside = (button: HTMLElement | null, menu: HTMLElement | null) => + button?.contains(e.target as Node) || menu?.contains(e.target as Node); + if (!inside(moveButtonRef.current, moveRef.current)) setMoveOpen(false); + if (!inside(tagButtonRef.current, tagRef.current)) setTagOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [moveOpen, tagOpen, moveRef, tagRef]); + + const selectionCount = selectedEmailIds.size; + const hasSelection = selectionCount > 0; + const allSelected = hasSelection && selectionCount === emails.length; + // Optimistic updates and demo fixtures can briefly leave the server total + // behind the rows already loaded. Never render an impossible "23 of 22". + const displayedTotal = Math.max(totalCount ?? loadedCount, loadedCount); + + const currentRole = mailboxes.find((mb) => mb.id === selectedMailbox)?.role + ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined); + const isInJunk = currentRole === "junk"; + const isInTrash = currentRole === "trash"; + // Marking your own drafts or sent mail as spam is meaningless. + const spamApplicable = !["sent", "drafts", "scheduled"].includes(currentRole ?? ""); + + const run = async (action: () => Promise) => { + if (!client || isProcessing) return; + setIsProcessing(true); + try { + await action(); + } finally { + setTimeout(() => setIsProcessing(false), 400); + } + }; + + const handleSelectAllToggle = () => { + if (disabled) return; + if (hasSelection) { + if (allSelected) clearSelection(); + else selectAllEmails(); + return; + } + if (emails.length > 0) selectAllEmails(); + }; + + const handleDelete = () => + run(async () => { + if (!client) return; + const confirmed = await confirmDialog({ + title: isInTrash ? t("permanent_delete_confirm_title") : tBatch("delete_confirm_title"), + message: isInTrash + ? t("permanent_delete_confirm_batch_message", { count: selectionCount }) + : tBatch("delete_confirm_message", { count: selectionCount }), + confirmText: isInTrash ? t("permanent_delete") : tBatch("delete"), + variant: "destructive", + }); + if (!confirmed) return; + await batchDelete(client, isInTrash); + }); + + const selectedEmails = emails.filter((email) => selectedEmailIds.has(email.id)); + + // The same targets the row's own context menu offers, with the account of + // the selection leading (#1149). + const { tree: moveTree, targetIds: moveTargetIds } = buildMoveTargets(mailboxes, { + currentMailboxId: selectedMailbox, + ownerAccountId: resolveMoveOwnerAccountId(selectedEmails[0], mailboxes, selectedMailbox), + }); + + const renderMoveNodes = (nodes: MailboxNode[], depth = 0): React.ReactNode => + nodes.map((node) => { + const label = localizeMailboxName(node.role, node.name, (key) => tSidebar(`mailboxes.${key}`)); + const isTarget = moveTargetIds.has(node.id); + return ( +
+ {isTarget ? ( + + ) : ( +
+ + {label} +
+ )} + {node.children.length > 0 && renderMoveNodes(node.children, depth + 1)} +
+ ); + }); + + // What the tag picker shows: a tag every selected message carries is + // checked, one only some of them carry is drawn as partial. + const tagCounts = new Map(); + for (const email of selectedEmails) { + for (const tagId of getEmailTagIds(email.keywords)) tagCounts.set(tagId, (tagCounts.get(tagId) ?? 0) + 1); + } + const appliedTags: string[] = []; + const partialTags: string[] = []; + for (const [tagId, count] of tagCounts) (count === selectedEmails.length ? appliedTags : partialTags).push(tagId); + + const handleMove = (mailboxId: string) => { + setMoveOpen(false); + void run(async () => { + if (client) await batchMoveToMailbox(client, mailboxId); + }); + }; + + // A tag the whole selection carries comes off; any other goes onto all of + // it, as in the default toolbar. + const handleToggleTag = (tagId: string) => { + const add = !appliedTags.includes(tagId); + void run(async () => { + if (client) await batchSetTag(client, tagId, add); + }); + }; + + const handleSpam = () => + run(async () => { + if (!client) return; + const ids = Array.from(selectedEmailIds); + if (isInJunk) await batchUndoSpam(client, ids); + else await batchMarkAsSpam(client, ids); + }); + + return ( +
+ + + + +
+ + {menuOpen && ( +
+ + {onMarkAllFoldersRead && ( + + )} + {/* Offered only where emptying a folder is the usual thing to + do: spam and the bin. */} + {onEmptyFolder && (isInJunk || isInTrash) && ( + <> +
+ + + )} +
+ )} +
+ + {hasSelection && !disabled && ( + <> +
+ run(async () => { if (client) await batchArchive(client); })} + disabled={isProcessing} + busy={isProcessing} + /> + {spamApplicable && ( + + )} + +
+ {moveTree.length > 0 && ( +
+ + {moveOpen && ( +
+ {renderMoveNodes(moveTree)} +
+ )} +
+ )} +
+ + {tagOpen && ( +
+ +
+ )} +
+
+ run(async () => { if (client) await batchMarkAsRead(client, true); })} + disabled={isProcessing} + /> + run(async () => { if (client) await batchMarkAsRead(client, false); })} + disabled={isProcessing} + /> + + {tBatch("selected_messages", { count: selectionCount })} + + + )} + +
+ + {loadedCount > 0 && ( + + {/* The range readout. The list grows as it scrolls rather than + paging, so there are no previous/next arrows to go with it. */} + {t("conversations_range", { from: 1, to: loadedCount, total: displayedTotal })} + + )} + + +
+ ); +} diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index f8b7ea24c..96a0545a1 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -12,6 +12,7 @@ import { SelectableAvatar } from "@/components/email/selectable-avatar"; import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder, Archive, Trash2, MailOpen, ShieldAlert } from "@/components/icons"; import type { AppIcon } from "@/components/icons"; import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store"; +import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout"; import { accountTintKey, generateAvatarColor } from "@/lib/account-utils"; import { useUIStore } from "@/stores/ui-store"; import { useEmailStore } from "@/stores/email-store"; @@ -66,6 +67,101 @@ function RowChips({ email, loadAttachments, onOpenAttachment }: { return leading ?
{leading}
: null; } +/** + * Mountain View's leading controls: a checkbox and a star, both always + * visible and both acting on the row without opening it. They take the slot + * the default layout gives to the sender avatar. + */ +function MvRowLead({ + checked, + onToggleChecked, + starred, + onToggleStar, + selectLabel, + starLabel, +}: { + checked: boolean; + onToggleChecked: (e: React.MouseEvent) => void; + starred: boolean; + onToggleStar?: () => void; + selectLabel: string; + starLabel: string; +}) { + return ( +
+ + {onToggleStar && ( + + )} +
+ ); +} + +function MvMobileStar({ + starred, + onToggle, + label, +}: { + starred: boolean; + onToggle?: () => void; + label: string; +}) { + return ( + + ); +} + function StatusIcon({ icon: Icon, label, className }: { icon: AppIcon; label: string; className: string }) { return ( <> @@ -178,6 +274,7 @@ const SingleEmailItem = React.forwardRef( function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, rowTint, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetTag, onMarkAsSpam, onUndoSpam, onOpenAttachment, loadAttachments }, ref) { const t = useTranslations('email_viewer'); const tBatch = useTranslations('email_list.batch_actions'); + const tHover = useTranslations('settings.email_behavior.hover_actions'); const tStatus = useTranslations('email_list'); const isUnread = !email.keywords?.$seen; const isStarred = email.keywords?.$flagged; @@ -198,9 +295,10 @@ const SingleEmailItem = React.forwardRef( const tintListRowsByTag = useSettingsStore((state) => state.tintListRowsByTag); const tintListRowsByAccount = useSettingsStore((state) => state.tintListRowsByAccount); const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const mailLayout = useEffectiveMailLayout(); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); + const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk; // Show the originating folder in the aggregate "All …" views. const showSourceFolder = isUnifiedView && !!email.sourceFolder; @@ -212,6 +310,12 @@ const SingleEmailItem = React.forwardRef( const isMobile = useUIStore((state) => state.isMobile); // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; + // Mountain View leads its rows with a checkbox and a clickable star + // instead of an avatar, and marks unread with weight alone. Only the + // one-line layout is shaped that way, so the two conditions travel + // together. + const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout; + const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile; const trimmedPreview = cleanPreview(email.preview); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // Search hits carry server snippets with the matched terms marked; they @@ -337,19 +441,23 @@ const SingleEmailItem = React.forwardRef( data-subject={email.subject || ''} data-unread={isUnread ? 'true' : 'false'} data-starred={email.keywords?.$flagged ? 'true' : 'false'} + data-mv-mobile-row={mvMobileRow ? "" : undefined} aria-current={selected ? 'true' : undefined} className={cn( "relative group cursor-pointer select-none transition-shadow duration-200 border-b border-border overflow-hidden", + mvMobileRow && "border-b-transparent", resolvedRowTint ? resolvedRowTint : ( selected ? "bg-accent" - : "bg-background" + : mvRow && !isUnread + ? "bg-muted/60" + : "bg-background" ), selected && !resolvedRowTint && "shadow-sm", !resolvedRowTint && !selected && !isChecked && "hover:bg-muted hover:shadow-sm", !resolvedRowTint && (selected || isChecked) && "hover:bg-accent hover:shadow-sm", resolvedRowTint && "hover:brightness-95 dark:hover:brightness-110", - isUnread && !resolvedRowTint && "bg-accent/30", + isUnread && !resolvedRowTint && !mvRow && !mvMobileRow && "bg-accent/30", isChecked && "ring-2 ring-primary/20", isChecked && !resolvedRowTint && "bg-accent/40", isDragging && "opacity-50 scale-[0.98] ring-2 ring-primary/30", @@ -384,7 +492,7 @@ const SingleEmailItem = React.forwardRef( style={{ gap: 'var(--density-item-gap)', paddingBlock: 'var(--density-item-py)', transform: swipeEnabled && offsetX ? `translateX(${offsetX}px)` : undefined, transition: swipeEnabled && offsetX === 0 ? 'transform 200ms ease-out' : undefined }} > {/* Checkbox - only for extra-compact density (no avatar) while in selection mode */} - {density === 'extra-compact' && selectedEmailIds.size > 0 && ( + {!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && ( )} - {density !== 'extra-compact' && ( + {mvRow && ( + + )} + + {!mvRow && density !== 'extra-compact' && (
(
)} - {isUnread && density === 'extra-compact' && ( + {isUnread && !mvRow && density === 'extra-compact' && ( )}
{isFocusedMailLayout ? ( + <>
{isUnifiedView && email.accountId && accountColor && !tintListRowsByAccount && ( @@ -469,7 +589,7 @@ const SingleEmailItem = React.forwardRef(
{isPinned && } - {isStarred && } + {isStarred && !mvRow && } {isAnswered && !isForwarded && } {isForwarded && !isAnswered && } {isAnswered && isForwarded && ( @@ -498,6 +618,10 @@ const SingleEmailItem = React.forwardRef( )}
+ {mvRow && ( + + )} + ) : ( <>
@@ -530,7 +654,7 @@ const SingleEmailItem = React.forwardRef( {isPinned && ( )} - {isStarred && ( + {isStarred && !mvMobileRow && ( )} {isAnswered && !isForwarded && ( @@ -570,6 +694,13 @@ const SingleEmailItem = React.forwardRef( {formatDate(email.receivedAt)} )} + {mvMobileRow && !(showPreview && density !== 'extra-compact' && density !== 'compact') && ( + + )}
@@ -592,14 +723,24 @@ const SingleEmailItem = React.forwardRef(
{showPreview && density !== 'extra-compact' && density !== 'compact' && ( -

- {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} -

+
+

+ {previewSnippet ? : (trimmedPreview || t('no_preview_available'))} +

+ {mvMobileRow && ( + + )} +
)} @@ -654,16 +795,24 @@ export const ThreadListItem = React.forwardRef state.showPreview); const density = useSettingsStore((state) => state.density); - const mailLayout = useSettingsStore((state) => state.mailLayout); + const mailLayout = useEffectiveMailLayout(); const timeFormat = useSettingsStore((state) => state.timeFormat); const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk); + const interfaceLayout = useSettingsStore((state) => state.interfaceLayout); const isMobile = useUIStore((state) => state.isMobile); const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread; // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile. const isFocusedMailLayout = mailLayout === 'focus' && !isMobile; + // Mountain View leads its rows with a checkbox and a clickable star + // instead of an avatar, and marks unread with weight alone. Only the + // one-line layout is shaped that way, so the two conditions travel + // together. + const mvRow = interfaceLayout === 'mountain-view' && isFocusedMailLayout; + const mvMobileRow = interfaceLayout === 'mountain-view' && isMobile; const trimmedPreview = cleanPreview(latestEmail.preview); const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : ''; // In a search the matched mail need not be the thread's latest one: show @@ -817,23 +966,31 @@ export const ThreadListItem = React.forwardRef +
{/* Checkbox for thread selection - only for extra-compact density (no avatar) while in selection mode */} - {density === 'extra-compact' && selectedEmailIds.size > 0 && ( + {!mvRow && density === 'extra-compact' && selectedEmailIds.size > 0 && ( +
+ )} + + {/* Account List. Under Mountain View the active account is already the + card above, so with a single login there is no list to draw. */} +
+ {listedAccounts.map((account) => { const isActive = account.id === activeAccountId; const isDraggable = !account.isDefault && accounts.length > 2; return ( @@ -304,11 +403,14 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher {account.isDefault && ( )} +

{toUnicodeEmail(account.email || account.username)}

-
+ {/* Server and connection state: Mountain View keeps the line + only for an account in trouble. */} +
{account.hasError ? ( ) : ( @@ -348,8 +450,36 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher })}
+ {/* Mountain View: "Add another account" and "Sign out of all accounts" as two + pills side by side. */} + {mvPopover && ( +
+ {accounts.length < getMaxAccounts() && ( + + )} + {accounts.length > 1 && ( + + )} +
+ )} + {/* Separator + Add Account */} - {accounts.length < getMaxAccounts() && ( + {!mvPopover && accounts.length < getMaxAccounts() && (
- {accounts.length > 1 && ( + {!mvPopover && accounts.length > 1 && (
); diff --git a/components/layout/mv-top-bar.tsx b/components/layout/mv-top-bar.tsx new file mode 100644 index 000000000..ae1a3a0a3 --- /dev/null +++ b/components/layout/mv-top-bar.tsx @@ -0,0 +1,335 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + BookUser, + Calendar, + Filter, + Grid3x3, + HardDrive, + Keyboard, + Mail, + Menu, + Settings, + iconForName, +} from "@/components/icons"; +import type { AppIcon as AppIconType } from "@/components/icons"; +import { useTranslations } from "next-intl"; +import { Link, usePathname } from "@/i18n/navigation"; +import { useAuthStore } from "@/stores/auth-store"; +import { useCalendarStore } from "@/stores/calendar-store"; +import { useConfig } from "@/hooks/use-config"; +import { usePolicyStore } from "@/stores/policy-store"; +import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; +import { useThemeStore } from "@/stores/theme-store"; +import { useUIStore } from "@/stores/ui-store"; +import { useMenuNavigation } from "@/hooks/use-menu-navigation"; +import { withBasePath } from "@/lib/browser-navigation"; +import { cn } from "@/lib/utils"; +import { AccountSwitcher } from "@/components/layout/account-switcher"; +import { SearchBox, type ContactSearchField } from "@/components/search/search-box"; +import type { ContactSuggestion } from "@/lib/search-suggestions"; + +interface MvTopBarProps { + searchQuery: string; + onSearchQueryChange: (value: string) => void; + onSearchSubmit: (query: string) => void; + onSearchClear: () => void; + onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void; + /** Opens the advanced-filter panel that lives under the message list. */ + onToggleFilters: () => void; + filtersOpen: boolean; + activeFilterCount: number; + searchDisabled?: boolean; + onShowShortcuts?: () => void; + onManageApps?: () => void; + onInlineApp?: (appId: string, url: string, name: string) => void; + onCloseInlineApp?: () => void; + activeAppId?: string | null; + /** Opens the merged across-accounts inbox from the account popover. */ + onSelectAllInboxes?: () => void; + allInboxesSelected?: boolean; + /** + * Receives the node under the search field that the advanced-filter panel is + * portalled into, so the panel drops from the field itself; without this + * the panel would stay behind in the message-list column, two bars away from + * the button that opens it. + */ + onFilterAnchorChange?: (node: HTMLDivElement | null) => void; +} + +/** + * Mountain View's global header: one 64px row that owns the hamburger, the + * brand, the search field and the account cluster. It only renders on + * desktop under `interfaceLayout === 'mountain-view'`; the default layout keeps + * search inside the list column and navigation in the left rail, and neither + * of those is touched here. + */ +export function MvTopBar({ + searchQuery, + onSearchQueryChange, + onSearchSubmit, + onSearchClear, + onSelectContact, + onToggleFilters, + filtersOpen, + activeFilterCount, + searchDisabled = false, + onShowShortcuts, + onManageApps, + onInlineApp, + onCloseInlineApp, + activeAppId, + onSelectAllInboxes, + allInboxesSelected = false, + onFilterAnchorChange, +}: MvTopBarProps) { + const t = useTranslations("sidebar"); + const tSearch = useTranslations("advanced_search"); + const pathname = usePathname(); + const { appName, appLogoLightUrl, appLogoDarkUrl } = useConfig(); + const resolvedTheme = useThemeStore((s) => s.resolvedTheme); + const toggleSidebarCollapsed = useUIStore((s) => s.toggleSidebarCollapsed); + + const client = useAuthStore((s) => s.client); + const { supportsCalendar } = useCalendarStore(); + const calendarEnabled = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled")); + const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled")); + const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled")); + const sidebarApps = useResolvedSidebarApps(); + + const [appsOpen, setAppsOpen] = useState(false); + const appsButtonRef = useRef(null); + const closeApps = useCallback(() => setAppsOpen(false), []); + const { menuRef: appsMenuRef, onKeyDown: onAppsKeyDown } = useMenuNavigation({ + open: appsOpen, + onClose: closeApps, + triggerRef: appsButtonRef, + }); + + useEffect(() => { + if (!appsOpen) return; + const onPointerDown = (e: MouseEvent) => { + if ( + appsButtonRef.current?.contains(e.target as Node) || + appsMenuRef.current?.contains(e.target as Node) + ) return; + setAppsOpen(false); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [appsOpen, appsMenuRef]); + + const apps: Array<{ id: string; icon: AppIconType; label: string; href: string }> = [ + { id: "mail", icon: Mail, label: t("mail"), href: "/" }, + ...(supportsCalendar && calendarEnabled + ? [{ id: "calendar", icon: Calendar, label: t("calendar"), href: "/calendar" }] + : []), + ...((client?.supportsContacts() ?? false) && contactsEnabled + ? [{ id: "contacts", icon: BookUser, label: t("contacts"), href: "/contacts" }] + : []), + ...((client?.supportsFiles() ?? false) && filesEnabled + ? [{ id: "files", icon: HardDrive, label: t("files"), href: "/files" }] + : []), + { id: "settings", icon: Settings, label: t("settings"), href: "/settings" }, + ]; + + const isActive = (href: string) => + !activeAppId && (href === "/" ? pathname === "/" || pathname === "" : pathname.startsWith(href)); + + const logoUrl = withBasePath( + resolvedTheme === "dark" ? appLogoDarkUrl || appLogoLightUrl : appLogoLightUrl || appLogoDarkUrl + ); + + return ( +
+ {/* Brand block: occupies the sidebar's own column width */} +
+ + + {logoUrl ? : null} + {appName} + +
+ + {/* Search: left-anchored and capped, not centred */} +
+ + + + {/* Anchor for the advanced-filter panel: the search field's own box, + filter button included, so the panel shares the field's edges. It + only marks the spot; the panel itself is drawn at the end of . */} +
+
+ + {/* Spacer keeps the right cluster pinned while search stays left */} +
+ +
+ {onShowShortcuts && ( + + )} + + + + +
+ + {appsOpen && ( +
+
+ {apps.map((app) => { + const Icon = app.icon; + return ( + { + if (activeAppId) onCloseInlineApp?.(); + setAppsOpen(false); + }} + className={cn( + "flex flex-col items-center gap-1.5 rounded-lg py-3 px-1 text-center transition-colors hover:bg-muted", + isActive(app.href) && "bg-muted" + )} + > + + {app.label} + + ); + })} + {sidebarApps.map((app) => { + const AppIcon = iconForName(app.icon); + return ( + + ); + })} +
+ {onManageApps && ( + + )} +
+ )} +
+ + +
+
+ ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index dd8940a5a..66cabd71d 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -213,6 +213,7 @@ export function NavigationRail({ const supportsFiles = client?.supportsFiles() ?? false; const supportsContacts = client?.supportsContacts() ?? false; const showRailAccountList = useSettingsStore((s) => s.showRailAccountList); + const mountainView = useSettingsStore((s) => s.interfaceLayout === 'mountain-view'); const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled('sidebarAppsEnabled')); const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled')); const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); @@ -338,7 +339,8 @@ export function NavigationRail({ return activeItemId === itemId; } if (href === "/") { - return pathname === "/" || pathname === ""; + // Mountain View's bottom bar marks the mail tab on mail routes too. + return pathname === "/" || pathname === "" || (mountainView && pathname.startsWith("/mail")); } return pathname.startsWith(href); }; @@ -360,7 +362,12 @@ export function NavigationRail({ if (orientation === "horizontal") { return (