diff --git a/app/globals.css b/app/globals.css index 0f61c3da8..389035287 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1047,10 +1047,38 @@ optgroup { border-end-end-radius: var(--mv-nav-radius); } -/* Compose: a 56px pill, which shrinks to a circle with the rail. */ +/* Selected and hovered rows fill the pill; a folder with unread mail reads + bold, the way the message rows do. Icons are 20px with an 18px gap. */ +[data-interface-layout="mountain-view"] [data-mv-nav-row] { + font-size: 0.875rem; + color: var(--color-foreground); +} +[data-interface-layout="mountain-view"] [data-mv-nav-row]:hover { + background-color: var(--mv-nav-hover); +} +[data-interface-layout="mountain-view"] [data-mv-nav-row][data-selected="true"] { + background-color: var(--mv-nav-selected); + color: var(--color-foreground); + font-weight: 700; +} +[data-interface-layout="mountain-view"] [data-mv-nav-row][data-has-unread] { + font-weight: 700; +} +[data-interface-layout="mountain-view"] [data-mv-nav-row] > button { + gap: 18px; +} +[data-interface-layout="mountain-view"] [data-mv-nav-row] > button > span:first-child, +[data-interface-layout="mountain-view"] [data-mv-nav-row] > button > span:first-child > svg { + width: 20px; + height: 20px; +} + +/* Compose: a 56px button with a 16px corner, which shrinks to a square with + the rail. */ [data-interface-layout="mountain-view"] [data-mv-compose] { height: var(--mv-compose-h); - border-radius: var(--mv-nav-radius); + border-radius: 16px; + font-weight: 500; } /* The account popover: a wide card with a very round corner, rather diff --git a/components/layout/__tests__/sidebar-mountain-view-flat-list.test.tsx b/components/layout/__tests__/sidebar-mountain-view-flat-list.test.tsx index 33899b6ec..619237b78 100644 --- a/components/layout/__tests__/sidebar-mountain-view-flat-list.test.tsx +++ b/components/layout/__tests__/sidebar-mountain-view-flat-list.test.tsx @@ -2,10 +2,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { Mailbox } from '@/lib/jmap/types'; -// Under Mountain View the sidebar is one flat list: inbox, sent and drafts, -// then a single "More" that reveals the folders you made, the shared ones and -// the labels. Outside Mountain View the "Folders" / "Tags" sections stay as they -// are, headings and all. +// Under Mountain View the sidebar is laid out the way Gmail's is: inbox, sent +// and drafts, a "More" that reveals the other system folders, then a +// "Labels" list of the folders you made and your tags. Outside Mountain View +// the "Folders" / "Tags" sections stay as they are, headings and all. vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null })); vi.mock('@/components/tour/tour-provider', () => ({ @@ -47,15 +47,27 @@ describe('Sidebar under Mountain View', () => { } as never); }); - it('drops the Folders heading and holds the rest behind More', () => { + it('drops the Folders heading and holds the system folders behind More', () => { render(); expect(screen.queryByText('folders')).toBeNull(); expect(screen.getByText('show_more')).toBeInTheDocument(); - expect(folderRow('Receipts')).toBeNull(); expect(folderRow('Trash')).toBeNull(); - // The labels heading goes with them: it shows only once expanded. + }); + + it('lists your folders and tags under Labels, outside More', () => { + const onCreateFolder = vi.fn(); + const { container } = render( + , + ); + + expect(screen.getByText('labels')).toBeInTheDocument(); expect(screen.queryByText('tags')).toBeNull(); + expect(container.querySelector('[data-mv-labels] [data-folder-name="Receipts"]')).not.toBeNull(); + expect(screen.getByText('Work')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('create_label')); + expect(onCreateFolder).toHaveBeenCalledTimes(1); }); it('keeps the places mail actually goes in view', () => { @@ -83,16 +95,14 @@ describe('Sidebar under Mountain View', () => { expect(container.querySelector('[data-folder-name="mv-starred"]')).toBeNull(); }); - it('reveals the folders and the labels together, and takes them back', () => { + it('reveals the system folders under More, and takes them back', () => { render(); fireEvent.click(screen.getByText('show_more')); - expect(folderRow('Receipts')).not.toBeNull(); expect(folderRow('Trash')).not.toBeNull(); - expect(screen.getByText('tags')).toBeInTheDocument(); fireEvent.click(screen.getByText('show_fewer')); - expect(folderRow('Receipts')).toBeNull(); + expect(folderRow('Trash')).toBeNull(); }); it('leaves the default layout its sections and every folder', () => { diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index fc959c1e6..b2b3d7ad3 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -32,7 +32,8 @@ import { Loader2, AlertTriangle, NotebookPen, - PenSquare, + Pencil, + Plus, CalendarClock, BellOff, Mails, @@ -210,14 +211,33 @@ function getIconClass(isSelected: boolean, isVirtual: boolean, colorful: boolean function SidebarRowCounts({ unread, total, + role, onUnreadClick, }: { unread?: number; total?: number; + role?: string | null; isSelected: boolean; onUnreadClick?: () => void; }) { const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount); + const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view'); + // Mountain View counts one thing per row: what is unread, or for Drafts, + // how many there are, since a draft is never unread. + if (mountainView) { + const count = role === 'drafts' ? (total ?? 0) : (unread ?? 0); + if (count === 0) return null; + return ( + + {count.toLocaleString()} + + ); + } const unreadCount = unread ?? 0; const totalCount = showFolderTotalCount ? (total ?? 0) : 0; @@ -336,6 +356,7 @@ function SidebarRow({ data-mailbox-id={testMailboxId ?? undefined} data-shared={testShared ? 'true' : undefined} data-selected={isSelected ? 'true' : undefined} + data-has-unread={(unread ?? 0) > 0 && testRole !== 'drafts' ? '' : undefined} style={{ paddingBlock: 'var(--density-sidebar-py)' }} className={cn( "group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150", @@ -396,6 +417,7 @@ function SidebarRow({ @@ -922,7 +944,8 @@ export function Sidebar({ const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded; const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox); const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified); - const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons); + // Mountain View draws its navigation icons in the text colour. + const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons) && !mvShell; const tagCounts = useEmailStore(s => s.tagCounts); const accounts = useAccountStore(s => s.accounts); const connectedAccounts = accounts.filter(a => a.isConnected); @@ -1045,6 +1068,11 @@ export function Sidebar({ node.role === 'inbox' || node.role === 'sent' || node.role === 'drafts'; const primaryTree = mvFlat ? ownTree.filter(isPrimaryFolder) : ownTree; const secondaryTree = mvFlat ? ownTree.filter((node) => !isPrimaryFolder(node)) : []; + // Of those, the system folders (archive, spam, trash...) go behind "More", + // and the folders the user made are listed with the tags under "Labels", + // which is what they are to someone coming from Gmail. + const moreTree = secondaryTree.filter((node) => !!node.role); + const labelTree = secondaryTree.filter((node) => !node.role); // Everything under "More" is hidden together, so one flag covers the extra // folders, the shared accounts and the labels. const showSecondary = !mvFlat || moreExpanded; @@ -1107,7 +1135,7 @@ export function Sidebar({ return ( } + icon={} label={t('scheduled')} depth={account?.depth ?? 0} isSelected={!selectedKeyword && isScheduledSelected} @@ -1312,13 +1340,13 @@ export function Sidebar({ data-mv-compose="" data-tour="compose-button" className={cn( - "flex items-center bg-primary text-primary-foreground shadow-sm hover:shadow-md transition-shadow", + "flex items-center bg-[var(--mv-compose-bg)] text-[var(--mv-compose-fg)] hover:shadow-md transition-shadow", isCollapsed ? "w-14 justify-center" : "gap-3 ps-4 pe-6" )} title={t("compose_hint")} aria-label={t("compose")} > - + {!isCollapsed && {t("compose")}} @@ -1513,7 +1541,7 @@ export function Sidebar({ isCollapsed={false} /> )} - {mvFlat && moreExpanded && secondaryTree.map((node) => ( + {mvFlat && moreExpanded && moreTree.map((node) => ( )} + {mvFlat && !useMultiAccount && ( +
+
+ {t("labels")} + {onCreateFolder && ( + + )} +
+ {labelTree.map((node) => ( + + ))} +
+ )} + {!useMultiAccount && showSecondary && sharedAccounts.length > 0 && (
)} - {showSecondary && emailKeywords.length > 0 && ( + {(showSecondary || mvFlat) && emailKeywords.length > 0 && (
- - {((tagsExpanded && !isCollapsed) || isCollapsed) && ( + {/* Under Mountain View the tags continue the "Labels" list. */} + {!mvFlat && ( + + )} + {((tagsExpanded && !isCollapsed) || isCollapsed || mvFlat) && ( <> {visibleTagTree.map((node) => (