diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index 8d59efcb4..b7cdfa667 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -44,6 +44,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/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx index cfbce5667..b19cfff9c 100644 --- a/components/calendar/calendar-app.tsx +++ b/components/calendar/calendar-app.tsx @@ -92,6 +92,8 @@ import { import { useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; import { findMeetingLink } from "@/lib/event-links"; import { useDocumentTitle } from "@/hooks/use-document-title"; +import { MvTopBar } from "@/components/layout/mv-top-bar"; +import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -119,6 +121,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps = const isMobile = useIsMobile(); const isDesktop = useIsDesktop(); const isEmbedded = useIsEmbedded(); + // Mountain View's desktop chrome: its top bar in place of the left rail. + const mvShell = useMountainViewShell(isEmbedded); // When the pane (Pro shell) or window is narrower than `lg`, the sidebar // collapses into a burger-toggled overlay instead of taking inline space. const isNarrow = !isDesktop; @@ -1571,9 +1575,18 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{refreshIndicator} + {mvShell && ( + + )}
{/* Left Navigation Rail (hidden when embedded in Pro shell) */} - {!isMobile && !isEmbedded && ( + {!isMobile && !isEmbedded && !mvShell && (
{refreshIndicator} + {mvShell && ( + + )}
{/* Navigation Rail - desktop only (hidden when embedded in Pro shell) */} - {!isMobile && !isEmbedded && ( + {!isMobile && !isEmbedded && !mvShell && (
+ ({ 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.getByLabelText('more_actions')); + + 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 8ef07c360..319cafdef 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"; @@ -708,8 +709,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); @@ -761,6 +763,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 @@ -3057,6 +3068,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' })} @@ -4028,8 +4066,10 @@ export function EmailViewer({
)} - {/* Email Content Area */} -
+ {/* Email Content Area. Mountain View reads the conversation on one plain + sheet, so the reply buttons under the message sit on the page colour + rather than on a tinted strip. */} +
{/* === SENDER INFO (Desktop) === */} @@ -4064,6 +4104,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}
)}
@@ -4353,8 +4401,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 */} @@ -4396,9 +4444,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)}
@@ -5379,8 +5429,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..1631c972d --- /dev/null +++ b/components/email/mv-list-toolbar.tsx @@ -0,0 +1,531 @@ +"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 tCommon = useTranslations("common"); + const tViewer = useTranslations("email_viewer"); + + 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 +405,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 +452,34 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher })}
+ {/* Mountain View: "Add another account" and "Sign out of all accounts" as two + pills side by side; with a single login the second is plain "Sign out". */} + {mvPopover && ( +
+ {accounts.length < getMaxAccounts() && ( + + )} + +
+ )} + {/* 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..ec5cc51d5 --- /dev/null +++ b/components/layout/mv-top-bar.tsx @@ -0,0 +1,374 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { + BookUser, + Calendar, + Filter, + Grid3x3, + HardDrive, + Keyboard, + Mail, + Menu, + Settings, + Shield, + 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 { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; +import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; +import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; +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"; + +/** Mail search, which only the mail surface puts into the bar. */ +interface MvTopBarSearch { + query: string; + onQueryChange: (value: string) => void; + onSubmit: (query: string) => void; + onClear: () => void; + onSelectContact: (contact: ContactSuggestion, field: ContactSearchField) => void; + /** Opens the advanced-filter panel that lives under the message list. */ + onToggleFilters: () => void; + filtersOpen: boolean; + activeFilterCount: number; + disabled?: 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; +} + +interface MvTopBarProps { + /** + * The mail search field. Other surfaces leave it out and keep their own + * search where it was; the bar then carries the brand, the app grid and the + * account avatar only. + */ + search?: MvTopBarSearch; + /** The hamburger folds the mail navigation, so only mail shows it. */ + showMenuButton?: 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; +} + +/** + * 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'` (see + * useMountainViewShell), on every surface, in place of the left rail; 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({ + search, + showMenuButton = true, + onShowShortcuts, + onManageApps, + onInlineApp, + onCloseInlineApp, + activeAppId, + onSelectAllInboxes, + allInboxesSelected = false, +}: 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")); + // The same gate the navigation rail puts on its "add app" button. + const sidebarAppsEnabled = usePolicyStore((s) => s.isFeatureEnabled("sidebarAppsEnabled")); + const sidebarApps = useResolvedSidebarApps(); + const isStalwartAdmin = useStalwartAdmin(); + const hasUpdate = useUpdateStore(selectHasUpdate); + const updateSeverity = useUpdateStore((s) => s.status?.severity); + const updateImportant = updateSeverity === "security" || updateSeverity === "deprecated"; + + 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 */} +
+ {showMenuButton && ( + + )} + + {logoUrl ? : null} + {appName} + +
+ + {/* Search: left-anchored and capped, not centred */} + {search && ( +
+ + + + {/* 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 && ( + + )} + {/* Admin (Stalwart admins), as in the navigation rail this bar replaces. + A hard navigation because /admin lives outside the [locale] tree. */} + {isStalwartAdmin && ( + + + {hasUpdate && ( + + )} + + )} + + + + +
+ + {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 ( + + ); + })} +
+ {sidebarAppsEnabled && onManageApps && ( + + )} +
+ )} +
+ + +
+
+ ); +} diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index dd8940a5a..2021e30ed 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -17,16 +17,15 @@ import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; import { useUpdateStore, selectHasUpdate } from "@/stores/update-store"; -import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot"; import { getMaxAccounts } from "@/lib/account-utils"; import { isDocumentRTL } from "@/i18n/direction"; import { cn, formatFileSize } from "@/lib/utils"; import { useMenuNavigation } from "@/hooks/use-menu-navigation"; import { PluginSlot } from "@/components/plugins/plugin-slot"; +import { useStalwartAdmin } from "@/hooks/use-stalwart-admin"; import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal"; -import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; +import { getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { Avatar } from "@/components/ui/avatar"; -import { IS_LITE } from "@/lib/lite"; import { toUnicodeEmail } from "@/lib/idn"; interface NavItem { @@ -213,6 +212,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')); @@ -221,7 +221,7 @@ export function NavigationRail({ // removes the user's, so a pinned set still shows when custom apps are off. const visibleSidebarApps = useResolvedSidebarApps(); const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0; - const [isStalwartAdmin, setIsStalwartAdmin] = useState(false); + const isStalwartAdmin = useStalwartAdmin(); const hasUpdate = useUpdateStore(selectHasUpdate); const updateSeverity = useUpdateStore((s) => s.status?.severity); const startUpdatePolling = useUpdateStore((s) => s.startPolling); @@ -292,30 +292,6 @@ export function NavigationRail({ }; }, [logoutMenuOpen, logoutPopoverRef]); - useEffect(() => { - let cancelled = false; - const headers = getActiveAccountSlotHeaders(); - // No admin console in the static Lite build. - if (IS_LITE || !headers['X-JMAP-Cookie-Slot']) return; - apiFetch('/api/admin/auth', { headers }) - .then(res => res.json()) - .then(data => { - if (cancelled || !data.stalwartAdmin) return; - setIsStalwartAdmin(true); - // Only "auto" mode may mint the admin session here; in "password" - // mode the shield leads to /admin/login instead (#870). - if (!data.authenticated && data.stalwartAutoLogin === true) { - // Pre-create admin session so /admin works even after full page navigation - apiFetch('/api/admin/auth', { - method: 'POST', - headers: { 'Content-Type': 'application/json', ...headers }, - body: JSON.stringify({ stalwartAuth: true }), - }).catch(() => {}); - } - }) - .catch(() => {}); - return () => { cancelled = true; }; - }, []); const navItems: NavItem[] = [ { id: "mail", icon: Mail, labelKey: "mail", href: "/", badge: inboxUnread }, @@ -338,7 +314,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 +337,12 @@ export function NavigationRail({ if (orientation === "horizontal") { return (