+
@@ -297,10 +297,9 @@ export function SettingsSkeleton({ label, labels }: SettingsSkeletonProps) {
surface="settings"
label={label}
labels={labels}
- railBelow="lg"
active="settings"
// Mountain View: the page sits on a card inset from the canvas.
- rowClassName="lg:mv:mb-4 lg:mv:mx-4 lg:mv:rounded-[var(--mv-card-radius)] lg:mv:bg-background"
+ rowClassName="desk:mv:mb-4 desk:mv:mx-4 desk:mv:rounded-[var(--mv-card-radius)] desk:mv:bg-background"
>
diff --git a/components/boot-skeleton/shell.tsx b/components/boot-skeleton/shell.tsx
index 15f66019d..f4a441085 100644
--- a/components/boot-skeleton/shell.tsx
+++ b/components/boot-skeleton/shell.tsx
@@ -70,7 +70,7 @@ export function RailSkeleton({
}) {
const item = (on: boolean) =>
cn(
- "relative flex items-center justify-center w-10 h-10 rounded-md max-lg:min-h-[44px]",
+ "relative flex items-center justify-center w-10 h-10 rounded-md tap:min-h-[44px]",
on ? "bg-primary/10 text-primary" : "text-muted-foreground",
);
const footerItem = "flex items-center justify-center w-10 h-10 rounded-md";
@@ -125,7 +125,7 @@ export function RailSkeleton({
}
/**
- * The phone/tablet bottom bar (NavigationRail horizontal). `className` decides
+ * The phone bottom bar (NavigationRail horizontal). `className` decides
* at which widths it shows.
*/
export function BottomBarSkeleton({
@@ -239,11 +239,6 @@ interface AppShellSkeletonProps {
label?: string;
/** Rail item drawn as current. */
active?: RailApp;
- /**
- * Below which width the vertical rail gives way to the bottom bar: mail and
- * settings switch at 64rem, calendar, contacts, files and plugin pages at 48rem.
- */
- railBelow: "md" | "lg";
labels?: RailLabels;
/** Whether the Mountain View top bar holds the search field (mail does). */
mvSearch?: boolean;
@@ -251,7 +246,7 @@ interface AppShellSkeletonProps {
mvMenu?: boolean;
/** Extra classes for the row under the top bar (Mountain View card margins etc.). */
rowClassName?: string;
- /** Extra classes for the phone/tablet bottom bar, e.g. to hide it while a message is open. */
+ /** Extra classes for the phone bottom bar, e.g. to hide it while a message is open. */
bottomBarClassName?: string;
children: ReactNode;
}
@@ -273,7 +268,6 @@ export function AppShellSkeleton({
surface,
label,
active,
- railBelow,
labels,
mvSearch = false,
mvMenu = true,
@@ -294,21 +288,15 @@ export function AppShellSkeleton({
-
+
+ {/* The rail from the phone breakpoint up (PHONE_BELOW), the bottom bar below it. */}
-
+
{children}
-
+
>
);
diff --git a/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx b/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx
index 3169ffd45..14ec4d485 100644
--- a/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx
+++ b/components/calendar/__tests__/calendar-app-boot-skeleton.test.tsx
@@ -12,6 +12,7 @@ import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => false,
useIsDesktop: () => true,
+ useIsFullDesktop: () => true,
useIsTablet: () => false,
useMediaQuery: () => false,
}));
diff --git a/components/calendar/__tests__/calendar-app.test.tsx b/components/calendar/__tests__/calendar-app.test.tsx
index b48778c2b..91526ea73 100644
--- a/components/calendar/__tests__/calendar-app.test.tsx
+++ b/components/calendar/__tests__/calendar-app.test.tsx
@@ -15,7 +15,9 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
- useIsDesktop: () => media.desktop,
+ useIsDesktop: () => !media.mobile,
+ // `desktop` is the full desktop: below it the sidebar opens over the page.
+ useIsFullDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
diff --git a/components/calendar/__tests__/calendar-side-panel.test.tsx b/components/calendar/__tests__/calendar-side-panel.test.tsx
index 51b061ca5..9bb1fbf8d 100644
--- a/components/calendar/__tests__/calendar-side-panel.test.tsx
+++ b/components/calendar/__tests__/calendar-side-panel.test.tsx
@@ -16,7 +16,9 @@ vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/pl
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
- useIsDesktop: () => media.desktop,
+ useIsDesktop: () => !media.mobile,
+ // `desktop` is the full desktop: below it the sidebar opens over the page.
+ useIsFullDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 291c715b6..7d84f6e06 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -18,7 +18,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
-import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
+import { useIsFullDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useAccountPrincipalEmails } from "@/hooks/use-account-principal-emails";
import { Button } from "@/components/ui/button";
import { CalendarSkeleton, CalendarSkeletonBody, CalendarViewSkeleton, calendarSkeletonText } from "@/components/boot-skeleton/calendar-skeleton";
@@ -166,7 +166,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
const canSubscribe = !demoMode && !IS_LITE;
const tDeepLink = useTranslations("deep_link");
const isMobile = useIsMobile();
- const isDesktop = useIsDesktop();
+ // Below the full desktop the sidebar opens over the page.
+ const isFullDesktop = useIsFullDesktop();
const isEmbedded = useIsEmbedded();
// The Pro interface takes over this route; the Pro tab loads the data then.
const proInterfaceActive = useProInterfaceActive();
@@ -174,7 +175,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
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;
+ const isNarrow = !isFullDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index a28c84ea1..9f3c0c91a 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -46,7 +46,7 @@ import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
import { ResizeHandle } from "@/components/layout/resize-handle";
-import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
+import { useIsFullDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
@@ -174,7 +174,8 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const hasFetched = useRef(false);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const isMobile = useIsMobile();
- const isDesktop = useIsDesktop();
+ // Below the full desktop the sidebar opens over the page.
+ const isFullDesktop = useIsFullDesktop();
const locale = useLocale();
usePrintListener();
const printing = usePrinting();
@@ -190,7 +191,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const intentAppliedRef = useRef(false);
// Narrow pane (Pro split or small window): the categories sidebar collapses
// into a burger-toggled overlay.
- const isNarrow = !isDesktop;
+ const isNarrow = !isFullDesktop;
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
// A new view (a form, a group, a contact) replaces whatever the drawer was for.
diff --git a/components/email/__tests__/list-row-preview-lines.test.tsx b/components/email/__tests__/list-row-preview-lines.test.tsx
index d1686f78b..a32f33131 100644
--- a/components/email/__tests__/list-row-preview-lines.test.tsx
+++ b/components/email/__tests__/list-row-preview-lines.test.tsx
@@ -44,7 +44,7 @@ function renderRow(emails: Email[]) {
}
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: true,
diff --git a/components/email/__tests__/list-row-received-time.test.tsx b/components/email/__tests__/list-row-received-time.test.tsx
index d25f50b7c..c85986801 100644
--- a/components/email/__tests__/list-row-received-time.test.tsx
+++ b/components/email/__tests__/list-row-received-time.test.tsx
@@ -42,7 +42,7 @@ function renderRow(emails: Email[]) {
}
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
index 74e737138..043f7dbb9 100644
--- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
@@ -71,7 +71,7 @@ beforeEach(() => {
setActionDispatcher(dispatch);
useEmailStore.setState(initialEmailState, true);
useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set
() });
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never);
});
diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx
index eafb2c6ed..e1ec0f659 100644
--- a/components/email/__tests__/plugin-row-badges.test.tsx
+++ b/components/email/__tests__/plugin-row-badges.test.tsx
@@ -58,7 +58,7 @@ beforeEach(() => {
clearAllHooks();
clearRowBadgeCache();
resetRowBadgeRefresh();
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -120,7 +120,7 @@ describe('plugin badges in message-list rows', () => {
});
it('shows badges on a conversation row and on the phone layout', async () => {
- useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]);
render(row([
diff --git a/components/email/__tests__/thread-list-item-account-dot.test.tsx b/components/email/__tests__/thread-list-item-account-dot.test.tsx
index 53678ca47..d03b35645 100644
--- a/components/email/__tests__/thread-list-item-account-dot.test.tsx
+++ b/components/email/__tests__/thread-list-item-account-dot.test.tsx
@@ -68,7 +68,7 @@ const accountDot = (c: HTMLElement) =>
describe('ThreadListItem account dot (#912)', () => {
beforeEach(() => {
accountState.accounts = [ACCOUNT_TEAL, ACCOUNT_OTHER];
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
diff --git a/components/email/__tests__/thread-list-item-columns.test.tsx b/components/email/__tests__/thread-list-item-columns.test.tsx
index d745c0b27..98c275ee0 100644
--- a/components/email/__tests__/thread-list-item-columns.test.tsx
+++ b/components/email/__tests__/thread-list-item-columns.test.tsx
@@ -55,7 +55,7 @@ const sizeCells = (c: HTMLElement) => Array.from(c.querySelectorAll('[data-list-
describe('message list rows under the bottom reading pane (#262)', () => {
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: true,
@@ -78,8 +78,14 @@ describe('message list rows under the bottom reading pane (#262)', () => {
expect(screen.getByText(/Preview text/)).toBeInTheDocument();
});
- it('keeps stacked rows on a tablet, which shows the split layout', () => {
- useUIStore.setState({ isMobile: false, isTablet: true, isDesktop: false });
+ it('draws one line on a compact desktop too, which shows the bottom pane', () => {
+ useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true });
+ const { container } = renderEmails([makeEmail()]);
+ expect(container.querySelector('p')).toBeNull();
+ });
+
+ it('keeps stacked rows on a phone', () => {
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const { container } = renderEmails([makeEmail()]);
expect(container.querySelector('p')).not.toBeNull();
});
diff --git a/components/email/__tests__/thread-list-item-conversation-pane.test.tsx b/components/email/__tests__/thread-list-item-conversation-pane.test.tsx
index 711ae9d2a..d99977545 100644
--- a/components/email/__tests__/thread-list-item-conversation-pane.test.tsx
+++ b/components/email/__tests__/thread-list-item-conversation-pane.test.tsx
@@ -52,7 +52,7 @@ function renderRow(conversationInReader: boolean) {
}
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS], showPreview: true, mailLayout: 'split',
interfaceLayout: 'default', listColumns: false,
diff --git a/components/email/__tests__/thread-list-item-source-folder.test.tsx b/components/email/__tests__/thread-list-item-source-folder.test.tsx
index 4b7575ea4..582f0227c 100644
--- a/components/email/__tests__/thread-list-item-source-folder.test.tsx
+++ b/components/email/__tests__/thread-list-item-source-folder.test.tsx
@@ -67,7 +67,7 @@ const conversation = () => [
describe('ThreadListItem folder chip in search results (#1172)', () => {
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useSettingsStore.setState({
emailKeywords: [...DEFAULT_KEYWORDS],
showPreview: false,
@@ -165,7 +165,7 @@ describe('ThreadListItem sender line in a narrow list', () => {
};
beforeEach(() => {
- useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useSettingsStore.setState({ emailKeywords: [...DEFAULT_KEYWORDS], showPreview: false, mailLayout: 'split', interfaceLayout: 'default', showColorNames: false });
useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox', isUnifiedView: false, selectedKeyword: null, searchQuery: '', searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: 'archive' });
});
diff --git a/components/email/__tests__/thread-list-item.test.tsx b/components/email/__tests__/thread-list-item.test.tsx
index 00ccb6845..984ee9a8c 100644
--- a/components/email/__tests__/thread-list-item.test.tsx
+++ b/components/email/__tests__/thread-list-item.test.tsx
@@ -66,7 +66,7 @@ function renderRow(email: Email) {
}
beforeEach(() => {
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
});
describe('ThreadListItem tag badge', () => {
@@ -238,7 +238,7 @@ describe('ThreadListItem row content', () => {
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 });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const onToggleStar = vi.fn();
const [thread] = groupEmailsByThread([makeEmail()]);
const { container } = render(
@@ -268,7 +268,7 @@ describe('ThreadListItem opening a thread on the phone', () => {
function tap(interfaceLayout: 'mountain-view' | 'default') {
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
- useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const onOpenConversation = vi.fn();
@@ -305,7 +305,7 @@ describe('ThreadListItem opening a thread on the phone', () => {
describe('ThreadListItem keeping the selection when a message opens', () => {
function open(interfaceLayout: 'mountain-view' | 'default', emails: Email[]) {
useSettingsStore.setState({ interfaceLayout, mailLayout: 'split' });
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
useEmailStore.setState({ selectedEmailKeys: new Set(['other']), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const [thread] = groupEmailsByThread(emails);
@@ -348,7 +348,7 @@ describe('ThreadListItem whole row toggles in selection mode', () => {
} = {},
) {
useSettingsStore.setState({ interfaceLayout: layout, mailLayout: 'split', density: 'regular' } as never);
- useUIStore.setState({ isMobile: mobile, isTablet: false, isDesktop: !mobile });
+ useUIStore.setState({ isMobile: mobile, isCompact: false, isDesktop: !mobile });
useEmailStore.setState({ selectedEmailKeys: new Set(selected), selectedMailbox: 'inbox' });
const onEmailSelect = vi.fn();
const onEmailDoubleClick = vi.fn();
@@ -499,7 +499,7 @@ describe('ThreadListItem avatar gutter (#1381)', () => {
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 });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
const [thread] = groupEmailsByThread([makeEmail()]);
const { container } = render(
{
describe('ThreadListItem swipe on a conversation', () => {
it('archives the conversation\'s row like a single message\'s', () => {
- useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
useSettingsStore.setState({ swipeRightAction: 'archive', swipeLeftAction: 'delete' } as never);
const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' });
const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' });
@@ -954,6 +954,37 @@ describe('ThreadListItem swipe on a conversation', () => {
fireEvent.touchEnd(row);
expect(onArchive).toHaveBeenCalledWith(expect.objectContaining({ id: 'b' }));
});
+
+ // A tablet draws the compact desktop layout (600-1024px) and keeps the swipe;
+ // a mouse in a window that narrow does not get it.
+ it.each([
+ ['swipes on a touch screen of a compact desktop', true, 1],
+ ['does not swipe with a mouse', false, 0],
+ ])('%s', (_name, coarse, calls) => {
+ useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true });
+ useSettingsStore.setState({ swipeRightAction: 'archive', swipeLeftAction: 'delete' } as never);
+ const spy = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
+ matches: coarse && query === '(pointer: coarse) and (width < 64rem)', media: query, onchange: null,
+ addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {},
+ dispatchEvent: () => false,
+ }) as MediaQueryList);
+ try {
+ const first = makeEmail({ id: 'a', threadId: 't', receivedAt: '2024-01-15T09:00:00Z' });
+ const second = makeEmail({ id: 'b', threadId: 't', receivedAt: '2024-01-15T10:00:00Z' });
+ const [thread] = groupEmailsByThread([first, second]);
+ const onArchive = vi.fn();
+ const { container } = render(
+ {}} onEmailSelect={() => {}} onArchive={onArchive} onDelete={vi.fn()} />,
+ );
+ const row = container.querySelector('[data-unread]')!;
+ fireEvent.touchStart(row, { touches: [{ clientX: 10, clientY: 10 }] });
+ fireEvent.touchMove(row, { touches: [{ clientX: 160, clientY: 12 }] });
+ fireEvent.touchEnd(row);
+ expect(onArchive).toHaveBeenCalledTimes(calls);
+ } finally {
+ spy.mockRestore();
+ }
+ });
});
// R11: in an Arabic interface an English subject was cut on the wrong side.
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index db32890d1..2ecc2d701 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -130,11 +130,10 @@ import { useRouter } from "@/i18n/navigation";
import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
-import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
+import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
-import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
import { rescheduleFailureToast, sendFailureMessage } from "@/lib/send-failure-message";
@@ -1452,7 +1451,7 @@ export function EmailViewer({
const toolbarPosition = useSettingsStore((state) => state.toolbarPosition);
const showToolbarLabelsSetting = useSettingsStore((state) => state.showToolbarLabels);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
- const mailLayout = useEffectiveMailLayout();
+ const mailLayout = useDrawnMailLayout();
const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled);
const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse);
const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments);
@@ -1503,8 +1502,7 @@ export function EmailViewer({
const canCancelScheduled = isScheduled && email?.scheduledUndoStatus === 'pending';
- // Tablet list visibility
- const { isTablet, isMobile } = useDeviceDetection();
+ const { isCompact, isMobile } = useDeviceDetection();
// The top toolbar leaves the message column on desktops and tablets, so
// it runs over the side panels to the right edge.
const liftToolbar = !isMobile;
@@ -1525,8 +1523,8 @@ export function EmailViewer({
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
const threadSummaryProps = useMemo(
- () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop' }),
- [email?.threadId, mvConversationIds, isMobile, isTablet],
+ () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [], deviceType: isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop' }),
+ [email?.threadId, mvConversationIds, isMobile, isCompact],
);
// Mountain View: which other messages of the conversation are unfolded. A
// conversation opens with its unread messages unfolded, as Gmail does, so
@@ -1594,7 +1592,6 @@ export function EmailViewer({
// `sm:hidden` must not be trusted - the viewport may be desktop-sized
// while the pane is phone-sized.
const isPaneScoped = useIsPaneScoped();
- const { tabletListVisible } = useUIStore(useShallow((state) => ({ tabletListVisible: state.tabletListVisible })));
const { identities, client, isDemoMode, activeAccountId } = useAuthStore(useShallow((state) => ({ identities: state.identities, client: state.client, isDemoMode: state.isDemoMode, activeAccountId: state.activeAccountId })));
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// The standard mail UI supplies message routing for preview/download,
@@ -1909,7 +1906,7 @@ export function EmailViewer({
);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
- const pluginDeviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
+ const pluginDeviceType = isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop';
const pluginEmailProps = useMemo(() => ({ email: pluginEmailView, deviceType: pluginDeviceType }), [pluginEmailView, pluginDeviceType]);
// The text selected in the body, for the side panel (a plugin working on
// what the user highlights) and the right-click menu on it. Reset per
@@ -2089,7 +2086,7 @@ export function EmailViewer({
// Everything the decision depends on that can be read without a layout.
const layoutKey = (items: HTMLElement[]) => [
- toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isTablet, tabletListVisible,
+ toolbarWidthRef.current, toolbarPosition, showToolbarLabels, isFocusedMailLayout,
...items.map((item) => `${item.dataset.overflowPriority}:${item.className}:${item.textContent}`),
].join('|');
@@ -2223,8 +2220,7 @@ export function EmailViewer({
emailKeywords.length,
currentColor,
isInJunkFolder,
- isTablet,
- tabletListVisible,
+ isFocusedMailLayout,
onBack,
onMarkAsSpam,
onUndoSpam,
@@ -4369,7 +4365,7 @@ ${printWatermark ? printWatermark.html : ''}
<>
{/* Left: Reply actions */}
- {showBackButton && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
+ {showBackButton && (isMobile || isFocusedMailLayout) && onBack && (
@@ -4963,14 +4959,14 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mountain View lines the subject up with the sender's name below it. */}
{/* In a narrow pane the date takes its own line instead of squeezing the subject to a column. */}
{/* Back button (for below-subject mode on tablet) */}
- {toolbarPosition === 'below-subject' && (isMobile || (isTablet && !tabletListVisible) || (isFocusedMailLayout && !isMobile)) && onBack && (
+ {toolbarPosition === 'below-subject' && (isMobile || isFocusedMailLayout) && onBack && (
{/* Mountain View: print and full screen at the end of the subject line. */}
{mvViewer && (
-
+
{!devices.noPrint && (
{/* === SENDER INFO (Desktop) === */}
0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
+ className={cn("hidden desk:block bg-background px-6", !mvViewer && "border-b border-border", (mvViewer || conversationPane) && mvBefore.length > 0 && "border-t border-border", mvOpenIndex >= 0 && "cursor-pointer")}
style={mvViewer ? { paddingBlock: '0.75rem 0.5rem' } : { paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
@@ -5328,7 +5324,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Mobile/Tablet Sender Info - scrolls with content */}
= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
+ className={cn("desk:hidden bg-background border-b border-border px-4", mvOpenIndex >= 0 && "cursor-pointer", mvOpenIndex >= 0 && mvBefore.length > 0 && "border-t")}
style={{ paddingBlock: 'var(--density-header-py)' }}
data-mv-open-header-mobile={mvOpenIndex >= 0 ? "" : undefined}
onClick={mvOpenIndex >= 0 ? (e) => {
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 328f64a97..eb6c91992 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -205,7 +205,7 @@ export function ThreadConversationView({
const t = useTranslations();
const externalContent = useConversationExternalContent();
const emailIdList = emails.map((email) => email.id).join(',');
- const { isMobile: summaryOnPhone, isTablet: summaryOnTablet } = useDeviceDetection();
+ const { isMobile: summaryOnPhone, isCompact: summaryOnTablet } = useDeviceDetection();
const threadSummaryProps = useMemo(
() => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [], deviceType: summaryOnPhone ? 'mobile' : summaryOnTablet ? 'tablet' : 'desktop' }),
[thread.latestEmail.threadId, emailIdList, summaryOnPhone, summaryOnTablet],
@@ -336,8 +336,8 @@ function useConversationPluginProps(email: Email) {
const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
const bannerOffers = usePluginSlotOffers('email-banner').length;
const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
- const { isMobile, isTablet } = useDeviceDetection();
- const deviceType = isMobile ? 'mobile' : isTablet ? 'tablet' : 'desktop';
+ const { isMobile, isCompact } = useDeviceDetection();
+ const deviceType = isMobile ? 'mobile' : isCompact ? 'tablet' : 'desktop';
const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null, deviceType }), [email, wantsView, deviceType]);
const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
const actionTarget = useMessageActionTarget(email);
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 42b420155..1e4cb306a 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -20,6 +20,7 @@ import { useListRowShape, LIST_COLUMN_CLASS } from "@/hooks/use-list-row-shape";
import { SizeCell } from "./list-columns";
import { accountTintKey, generateAvatarColor } from "@/lib/account-utils";
import { useUIStore } from "@/stores/ui-store";
+import { useMediaQuery } from "@/hooks/use-media-query";
import { useEmailStore, isUnscopedSearchActive } from "@/stores/email-store";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { emailKeyFor, getThreadTagIds, getEmailTagIds } from "@/lib/thread-utils";
@@ -95,6 +96,16 @@ function useShowAccountDot(tintListRowsByAccount: boolean): boolean {
return enabled && severalAccounts && !tintListRowsByAccount;
}
+/**
+ * Rows swipe on a phone, and on a touch screen below the full desktop: a
+ * tablet draws the compact desktop layout and keeps the phone's gesture.
+ */
+function useSwipeRows(): boolean {
+ const isMobile = useUIStore((state) => state.isMobile);
+ const touchCompact = useMediaQuery("(pointer: coarse) and (width < 64rem)");
+ return isMobile || touchCompact;
+}
+
/** Hover actions mount on the first pointerover or keyboard focus in the row. */
function useArmOnPointerOver(): [boolean, () => void] {
const [armed, setArmed] = React.useState(false);
@@ -478,6 +489,7 @@ const SingleEmailItem = React.forwardRef
(
const announceRowPreview = useSettingsStore((state) => state.announceRowPreview);
const showRowActionButton = useSettingsStore((state) => state.showRowActionButton);
const isMobile = useUIStore((state) => state.isMobile);
+ const swipeRows = useSwipeRows();
// One line per row where the list has the full width; stacked on phones.
const { oneLine: oneLineRow, columns } = useListRowShape();
// Mountain View leads its rows with a checkbox and a clickable star
@@ -557,7 +569,7 @@ const SingleEmailItem = React.forwardRef(
};
const hasRight = isActionable(swipeRightAction);
const hasLeft = isActionable(swipeLeftAction);
- const swipeEnabled = isMobile && (hasLeft || hasRight);
+ const swipeEnabled = swipeRows && (hasLeft || hasRight);
const { swipeHandlers, offsetX, side: swipeSide, willCommit, consumeTap } = useSwipeActions({
enabled: swipeEnabled,
hasLeft,
@@ -1066,6 +1078,7 @@ export const ThreadListItem = React.forwardRef state.showAvatarsInJunk);
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
const isMobile = useUIStore((state) => state.isMobile);
+ const swipeRows = useSwipeRows();
const { latestEmail, participantNames, hasUnread, hasStarred, hasPinned, hasAnswered, hasForwarded, emailCount } = thread;
// Only the latest email's parts are loaded; an older one with the flag keeps the paperclip.
const latestHasAttachment = useListHasAttachment(latestEmail, loadAttachments);
@@ -1165,7 +1178,7 @@ export const ThreadListItem = React.forwardRef ({
- matches: narrow && query.includes('64rem') && query.includes('<'),
+ matches: narrow && query === '(width < 37.5rem)',
media: query,
onchange: null,
addListener: () => {},
diff --git a/components/layout/__tests__/sidebar-rail-peek-hover.test.tsx b/components/layout/__tests__/sidebar-rail-peek-hover.test.tsx
index 5e65bfbc5..2b09676a3 100644
--- a/components/layout/__tests__/sidebar-rail-peek-hover.test.tsx
+++ b/components/layout/__tests__/sidebar-rail-peek-hover.test.tsx
@@ -54,7 +54,7 @@ describe('Mountain View rail peek', () => {
it('shows folder names in the drawer of a narrow window, even when collapsed on a wider one', () => {
const matchMedia = window.matchMedia;
window.matchMedia = ((query: string) => ({
- matches: query.includes('64rem'), media: query, addEventListener() {}, removeEventListener() {},
+ matches: query === '(width < 37.5rem)', media: query, addEventListener() {}, removeEventListener() {},
})) as never;
try {
render( );
diff --git a/components/layout/mobile-header.tsx b/components/layout/mobile-header.tsx
index 3e2c99b10..e527e3159 100644
--- a/components/layout/mobile-header.tsx
+++ b/components/layout/mobile-header.tsx
@@ -61,7 +61,7 @@ export function MobileHeader({
const { toggleSidebar, goBack, sidebarOpen } = useUIStore(useShallow((state) => ({ toggleSidebar: state.toggleSidebar, goBack: state.goBack, sidebarOpen: state.sidebarOpen })));
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
// Pane-aware: in Pro split mode the viewport is desktop-wide while the
- // pane is narrow. The Tailwind `lg:hidden` variant alone would never fire
+ // pane is narrow. The Tailwind `desk:hidden` variant alone would never fire
// there, so we additionally hide via JS when the surrounding pane is
// desktop-sized. Outside of Pro this still returns the viewport value.
const isPaneDesktop = useIsDesktop();
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index dda7d0ad4..c123da7f6 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -658,7 +658,7 @@ export function NavigationRail({
className={cn(
"relative flex items-center gap-2.5 rounded-md transition-colors duration-150 cursor-pointer",
collapsed ? "justify-center w-10 h-10" : "px-2.5 text-sm",
- "max-lg:min-h-[44px]",
+ "tap:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? tGlobalSearch("title") : undefined}
@@ -682,7 +682,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
- "max-lg:min-h-[44px]",
+ "tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -726,7 +726,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
- "max-lg:min-h-[44px]",
+ "tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -772,7 +772,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
- "max-lg:min-h-[44px]",
+ "tap:min-h-[44px]",
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
@@ -795,7 +795,7 @@ export function NavigationRail({
collapsed
? "justify-center w-10 h-10"
: "px-2.5 text-sm",
- "max-lg:min-h-[44px]",
+ "tap:min-h-[44px]",
"text-muted-foreground hover:bg-muted hover:text-foreground"
)}
title={collapsed ? t("add_app") : undefined}
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 5f833e63a..1de32edcc 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -61,8 +61,7 @@ import { isAppShortcutBlocked, isEventInside } from "@/lib/keyboard";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useLongPress } from "@/hooks/use-long-press";
-import { useMediaQuery } from "@/hooks/use-media-query";
-import { usePaneSize } from "@/hooks/use-pane-size";
+import { useDeviceDetection, useMediaQuery } from "@/hooks/use-media-query";
import { IconButton } from "@/components/ui/icon-button";
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
@@ -73,7 +72,7 @@ import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
import { MAILBOX_DRAG_MIME } from "@/components/pro/pro-shell-drop";
import { useTagDrop } from "@/hooks/use-tag-drop";
-import { useUIStore } from "@/stores/ui-store";
+import { sidebarFolded, useUIStore } from "@/stores/ui-store";
import { useAuthStore } from "@/stores/auth-store";
import { unifiedLoadProgress } from "@/lib/unified-mailbox";
import { useVacationStore } from "@/stores/vacation-store";
@@ -441,7 +440,7 @@ function SidebarRow({
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",
+ "group w-full flex items-center tap:min-h-[44px] text-sm transition-colors duration-150",
isCollapsed ? "justify-center px-1" : "pe-2",
openFromRow && "cursor-pointer select-none",
isVirtual
@@ -1135,7 +1134,11 @@ function SidebarImpl({
mvShell = false,
}: SidebarProps) {
const router = useRouter();
- const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: state.sidebarCollapsed, toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
+ // The rail is a desktop thing. Below that the sidebar is a drawer, opened to
+ // read the folder names, whatever the collapse button last said on a wider
+ // window. A compact desktop (or Pro pane) starts with the rail.
+ const { isMobile: isDrawer, isCompact } = useDeviceDetection();
+ const { sidebarCollapsed, toggleSidebarCollapsed } = useUIStore(useShallow((state) => ({ sidebarCollapsed: sidebarFolded(state, isCompact), toggleSidebarCollapsed: state.toggleSidebarCollapsed })));
// Mountain View's collapsed rail is a peek, not a commitment: putting the pointer on
// it slides the full list back out over the message list and takes it away
// again on leaving, so a folder two clicks deep costs no clicks at all. It
@@ -1143,11 +1146,6 @@ function SidebarImpl({
// list behind it.
const [railHovered, setRailHovered] = useState(false);
const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
- // The rail is a desktop thing. Below that the sidebar is a drawer, opened to
- // read the folder names, whatever the collapse button last said on a wider window.
- const paneWidth = usePaneSize();
- const narrowWindow = useMediaQuery('(width < 64rem)');
- const isDrawer = paneWidth !== null ? paneWidth < 1024 : narrowWindow;
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState>(new Set());
@@ -1881,14 +1879,14 @@ function SidebarImpl({
// Mountain View's navigation sits on the canvas, with no rule between
// it and the content card.
mvShell ? "bg-[var(--mv-canvas)] border-transparent" : "bg-secondary border-border",
- "max-lg:w-full",
+ "max-desk:w-full",
// Mountain View's collapsed column is 72px (--mv-rail-w), wide enough
// for its round compose button; the default layout's rail is 48px.
- isCollapsed && !mvShell ? "lg:w-12" : "lg:w-full",
+ isCollapsed && !mvShell ? "desk:w-12" : "desk:w-full",
// Floats out of the rail's own column rather than widening it, so the
// message list underneath never moves while the pointer passes over.
// Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek.
- hoverExpanded && "lg:absolute lg:inset-y-0 lg:start-0 lg:z-40 lg:shadow-xl",
+ hoverExpanded && "desk:absolute desk:inset-y-0 desk:start-0 desk:z-40 desk:shadow-xl",
className
)}
>
@@ -1903,7 +1901,7 @@ function SidebarImpl({
variant="ghost"
size="icon"
onClick={onSidebarClose}
- className="lg:hidden h-9 w-9 flex-shrink-0"
+ className="desk:hidden h-9 w-9 flex-shrink-0"
aria-label={t("close")}
>
@@ -1913,7 +1911,7 @@ function SidebarImpl({
variant="ghost"
size="icon"
onClick={toggleSidebarCollapsed}
- className="hidden lg:flex h-8 w-8 flex-shrink-0"
+ className="hidden desk:flex h-8 w-8 flex-shrink-0"
title={isCollapsed ? t("expand_tooltip") : t("collapse_tooltip")}
>
{isCollapsed ? : }
diff --git a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
index 920c50b20..3b054a4bb 100644
--- a/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
+++ b/components/mail/__tests__/mail-app-boot-skeleton.test.tsx
@@ -152,11 +152,11 @@ describe('mail page hand-off from the boot skeleton', () => {
const { getByTestId, container } = render( );
await act(async () => {});
const reader = container.querySelector('[data-pane="reader"]')!;
- expect(reader.className).not.toMatch(/(^|\s)md:hidden(\s|$)/);
+ expect(reader.className).not.toMatch(/(^|\s)desk:hidden(\s|$)/);
expect(getByTestId('viewer').hasAttribute('data-loading')).toBe(true);
// Not found: the reader goes away again and the list takes the page.
await act(async () => { content.resolve(null); });
- await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)md:hidden(\s|$)/));
+ await waitFor(() => expect(container.querySelector('[data-pane="reader"]')!.className).toMatch(/(^|\s)desk:hidden(\s|$)/));
});
it('opens a linked message over the list on a phone and hides the bottom bar meanwhile', async () => {
@@ -165,7 +165,7 @@ describe('mail page hand-off from the boot skeleton', () => {
const { container, queryByTestId } = render( );
await act(async () => {});
const reader = container.querySelector('[data-pane="reader"]')!;
- expect(reader.className).not.toMatch(/(^|\s)max-md:hidden(\s|$)/);
+ expect(reader.className).not.toMatch(/(^|\s)max-desk:hidden(\s|$)/);
expect(queryByTestId('bottom-nav')).toBeNull();
});
diff --git a/components/mail/__tests__/mail-app-short-window-tab-bar.test.tsx b/components/mail/__tests__/mail-app-compact-layout.test.tsx
similarity index 58%
rename from components/mail/__tests__/mail-app-short-window-tab-bar.test.tsx
rename to components/mail/__tests__/mail-app-compact-layout.test.tsx
index 8e4932e68..4228c3407 100644
--- a/components/mail/__tests__/mail-app-short-window-tab-bar.test.tsx
+++ b/components/mail/__tests__/mail-app-compact-layout.test.tsx
@@ -1,5 +1,5 @@
import React from 'react';
-import { fireEvent, render, screen } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
@@ -10,20 +10,29 @@ import { useThemeStore } from '@/stores/theme-store';
import { useUIStore } from '@/stores/ui-store';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
-// A phone on its side: the bottom bar takes a sixth of the window, so it goes
-// over what fills the window. It stays wherever the list shows, or there is no
-// way left to the other apps.
+// A window between the phone and the full desktop (a half-screen window, a
+// laptop at 150 %) is a compact desktop: the app rail at the side, the folder
+// sidebar folded to its icons, and no phone tab bar (usability test
+// 2026-11-04, mail-28 and settings-16).
-const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: true, isDesktop: false }, short: true }));
+const device = vi.hoisted(() => ({
+ current: { isMobile: false, isCompact: true, isDesktop: true },
+ splitFits: true,
+}));
-vi.mock('@/hooks/use-media-query', () => ({
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal()),
useDeviceDetection: () => device.current,
- useMediaQuery: (query: string) => query.includes('max-height') && device.short,
- useIsDesktop: () => false,
+ useMediaQuery: () => false,
+ useIsDesktop: () => device.current.isDesktop,
+ useIsMobile: () => device.current.isMobile,
+}));
+vi.mock('@/hooks/use-split-panes-fit', () => ({ useSplitPanesFit: () => device.splitFits }));
+vi.mock('@/components/layout/navigation-rail', () => ({
+ NavigationRail: ({ orientation = 'vertical' }: { orientation?: string }) => rail ,
}));
-vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => tab bar }));
vi.mock('@/components/layout/sidebar', () => ({
- Sidebar: ({ onCompose }: { onCompose: () => void }) => stub compose ,
+ Sidebar: () => stub folders ,
}));
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => stub composer
}));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => message viewer
}));
@@ -33,6 +42,7 @@ vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ Cale
vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/plugins/plugin-side-panels', () => ({ PluginSidePanel: () => null }));
vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
@@ -52,9 +62,13 @@ const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0 } as Mailbox;
const message = { id: 'm1', threadId: 't-m1', subject: 'm1', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
+const compact = { isMobile: false, isCompact: true, isDesktop: true };
+const full = { isMobile: false, isCompact: false, isDesktop: true };
+const phone = { isMobile: true, isCompact: false, isDesktop: false };
+
beforeEach(() => {
- device.current = { isMobile: false, isTablet: true, isDesktop: false };
- device.short = true;
+ device.current = compact;
+ device.splitFits = true;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
@@ -68,8 +82,12 @@ beforeEach(() => {
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
- useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
- useUIStore.setState({ activeView: 'list', tabletListVisible: true, sidebarOpen: false });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', simpleMode: false } as never);
+ useUIStore.setState({
+ activeView: 'list', sidebarOpen: false, sidebarWidth: 256,
+ sidebarCollapsed: false, compactSidebarExpanded: false,
+ isMobile: false, isCompact: true, isDesktop: true,
+ });
useEmailStore.setState({
emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
@@ -88,42 +106,60 @@ afterEach(() => {
vi.unstubAllGlobals();
});
-const tabBar = () => screen.queryByText('tab bar');
+/** The element holding the folder sidebar (the one with its width). */
+const folders = () => screen.getByText('stub folders').closest('[id]')!;
+const rails = () => Array.from(document.querySelectorAll('[data-rail]')).map((el) => el.dataset.rail);
+const listPane = (c: HTMLElement) => c.querySelector('section[aria-labelledby]')!;
-describe('the bottom bar in a short window', () => {
- it('stays with the list beside an open message', () => {
- useEmailStore.setState({ selectedEmail: message } as never);
+describe('the compact desktop', () => {
+ it('has the app rail at the side and no phone tab bar', () => {
render( );
- expect(tabBar()).not.toBeNull();
+ expect(rails()).toEqual(['vertical']);
});
- it('goes when the message fills the window', () => {
- useUIStore.setState({ tabletListVisible: false });
- useEmailStore.setState({ selectedEmail: message } as never);
+ it('folds the folder sidebar to its icons, in the flow', () => {
render( );
- expect(tabBar()).toBeNull();
+ expect(folders().style.width).toBe('48px');
+ expect(folders().hasAttribute('inert')).toBe(false);
});
- it('goes over a composer, and comes back without one', async () => {
+ it('opens the folders at their width when asked, without touching the full desktop choice', () => {
+ useUIStore.getState().toggleSidebarCollapsed();
render( );
- expect(tabBar()).not.toBeNull();
- fireEvent.click(screen.getByText('stub compose'));
- await screen.findByText('stub composer', {}, { timeout: 5000 });
- expect(tabBar()).toBeNull();
+ expect(folders().style.width).toBe('256px');
+ expect(useUIStore.getState().sidebarCollapsed).toBe(false);
});
- it('stays in a tall window whatever is open', () => {
- device.short = false;
- useUIStore.setState({ tabletListVisible: false });
+ it('opens a message in the list\'s place when the window is too narrow for both', () => {
+ device.splitFits = false;
useEmailStore.setState({ selectedEmail: message } as never);
- render( );
- expect(tabBar()).not.toBeNull();
+ const { container } = render( );
+ expect(listPane(container).hasAttribute('inert')).toBe(true);
});
- it('stays on a phone\'s list with a message kept selected behind it', () => {
- device.current = { isMobile: true, isTablet: false, isDesktop: false };
+ it('keeps the list beside the message where both fit', () => {
useEmailStore.setState({ selectedEmail: message } as never);
+ const { container } = render( );
+ expect(listPane(container).hasAttribute('inert')).toBe(false);
+ });
+});
+
+describe('the full desktop', () => {
+ it('keeps the folders at their width', () => {
+ device.current = full;
+ useUIStore.setState({ isCompact: false });
render( );
- expect(tabBar()).not.toBeNull();
+ expect(folders().style.width).toBe('256px');
+ expect(rails()).toEqual(['vertical']);
+ });
+});
+
+describe('the phone', () => {
+ it('keeps the tab bar and the folders in a closed drawer', () => {
+ device.current = phone;
+ useUIStore.setState({ isMobile: true, isCompact: false, isDesktop: false });
+ render( );
+ expect(rails()).toEqual(['horizontal']);
+ expect(folders().hasAttribute('inert')).toBe(true);
});
});
diff --git a/components/mail/__tests__/mail-app-compose-handoff.test.tsx b/components/mail/__tests__/mail-app-compose-handoff.test.tsx
index 11ad54128..312237c87 100644
--- a/components/mail/__tests__/mail-app-compose-handoff.test.tsx
+++ b/components/mail/__tests__/mail-app-compose-handoff.test.tsx
@@ -69,11 +69,14 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+// A compact desktop: rail and folders beside the list, the composer in the pane.
+const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: true, isDesktop: true } }));
vi.mock('@/hooks/use-media-query', () => ({
- useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }),
+ useDeviceDetection: () => device.current,
useMediaQuery: () => false,
- useIsDesktop: () => false,
+ useIsDesktop: () => true,
atLeast: (px: number) => `(width >= ${px}px)`,
+ PHONE_BELOW: 600,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
@@ -208,8 +211,11 @@ describe('a composer coming back from its own window', () => {
expect(seen.sourceEmailId).toBe('a');
});
+ // Mountain View docks the composer of a desktop in its own window; on a
+ // phone it fills the reading pane as in the default layout.
it('under Mountain View too', async () => {
useSettingsStore.setState({ interfaceLayout: 'mountain-view' } as never);
+ device.current = { isMobile: true, isCompact: false, isDesktop: false };
render( );
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 50)); });
const answer = comeBack('r1');
@@ -217,6 +223,7 @@ describe('a composer coming back from its own window', () => {
await waitFor(() => expect(answer).toHaveBeenCalledWith('shown'));
expect(seen.sourceEmailId).toBe('a');
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
+ device.current = { isMobile: false, isCompact: true, isDesktop: true };
});
it('under Pro it becomes a tab that answers its own message, not the one selected in the list', async () => {
diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx
index 2be7bc295..9b216b2b0 100644
--- a/components/mail/__tests__/mail-app-list-ux.test.tsx
+++ b/components/mail/__tests__/mail-app-list-ux.test.tsx
@@ -14,7 +14,7 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// and, with messages selected, the selection's count and buttons (mail-10).
// The New folder dialogs are titled without the menu item's "…" (mail-29).
-const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
+const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: false, isDesktop: true } }));
const listProps = vi.hoisted(() => ({ host: undefined as HTMLElement | null | undefined }));
vi.mock('@/hooks/use-media-query', () => ({
@@ -71,7 +71,7 @@ const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 1, unrea
const message = { id: 'm1', threadId: 't-m1', subject: 'm1', receivedAt: '2026-10-31T10:00:00Z', keywords: { $seen: true }, mailboxIds: { inbox: true } } as unknown as Email;
beforeEach(() => {
- device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ device.current = { isMobile: false, isCompact: false, isDesktop: true };
listProps.host = undefined;
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
@@ -87,7 +87,7 @@ beforeEach(() => {
getAllConnectedClients: () => new Map([['login-a', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
- useUIStore.setState({ activeView: 'list', tabletListVisible: true, sidebarOpen: false });
+ useUIStore.setState({ activeView: 'list', sidebarOpen: false });
useEmailStore.setState({
emails: [message], selectedEmail: null, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
@@ -121,7 +121,7 @@ describe('the first-run tips', () => {
});
it('stay above the list on a phone', () => {
- device.current = { isMobile: true, isTablet: false, isDesktop: false };
+ device.current = { isMobile: true, isCompact: false, isDesktop: false };
render( );
expect(screen.getByTestId('welcome-list')).toBeTruthy();
expect(screen.queryByTestId('welcome-pane')).toBeNull();
diff --git a/components/mail/__tests__/mail-app-mv-composer-move.test.tsx b/components/mail/__tests__/mail-app-mv-composer-move.test.tsx
index 117248914..4d067acce 100644
--- a/components/mail/__tests__/mail-app-mv-composer-move.test.tsx
+++ b/components/mail/__tests__/mail-app-mv-composer-move.test.tsx
@@ -14,7 +14,7 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// depending on the width, and React mounts it anew in each. What was typed has
// to come along, or a narrowed window silently empties the message.
-const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
+const device = vi.hoisted(() => ({ current: { isMobile: false, isCompact: false, isDesktop: true } }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({
@@ -76,7 +76,7 @@ const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
beforeEach(() => {
- device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ device.current = { isMobile: false, isCompact: false, isDesktop: true };
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
@@ -113,17 +113,19 @@ describe('moving the Mountain View composer between layouts', () => {
fireEvent.click(screen.getByText('stub compose'));
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Still here' } });
- device.current = { isMobile: false, isTablet: true, isDesktop: false };
+ device.current = { isMobile: true, isCompact: false, isDesktop: false };
rerender( );
await waitFor(() => expect(screen.getByLabelText('stub subject').value).toBe('Still here'));
- device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ device.current = { isMobile: false, isCompact: false, isDesktop: true };
rerender( );
await waitFor(() => expect(screen.getByLabelText('stub subject').value).toBe('Still here'));
});
- const setWidth = (rerender: (ui: React.ReactElement) => void, width: 'desktop' | 'tablet') => {
- device.current = { isMobile: false, isTablet: width === 'tablet', isDesktop: width === 'desktop' };
+ // Mountain View floats its composer on every desktop, compact included; a
+ // phone has it in the reading pane.
+ const setWidth = (rerender: (ui: React.ReactElement) => void, width: 'desktop' | 'phone') => {
+ device.current = { isMobile: width === 'phone', isCompact: false, isDesktop: width === 'desktop' };
rerender( );
};
const subject = () => screen.getByLabelText('stub subject');
@@ -134,13 +136,13 @@ describe('moving the Mountain View composer between layouts', () => {
await screen.findByLabelText('stub subject');
// Clean, so this move hands nothing over.
- setWidth(rerender, 'tablet');
+ setWidth(rerender, 'phone');
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'Typed in the pane' } });
setWidth(rerender, 'desktop');
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
- setWidth(rerender, 'tablet');
+ setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('Typed in the pane'));
});
@@ -149,25 +151,25 @@ describe('moving the Mountain View composer between layouts', () => {
fireEvent.click(screen.getByText('stub compose'));
fireEvent.change(await screen.findByLabelText('stub subject'), { target: { value: 'First' } });
- setWidth(rerender, 'tablet');
+ setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('First'));
setWidth(rerender, 'desktop');
await waitFor(() => expect(subject().value).toBe('First'));
fireEvent.change(subject(), { target: { value: 'First and more' } });
- setWidth(rerender, 'tablet');
+ setWidth(rerender, 'phone');
await waitFor(() => expect(subject().value).toBe('First and more'));
});
});
-describe('a composer opened from the phone and tablet drawer', () => {
+describe('a composer opened from the phone drawer', () => {
const openFromInsideDrawer = () => window.dispatchEvent(new CustomEvent('bulwark:mailto', {
detail: { to: ['bob@example.org'], cc: [], bcc: [], subject: '', body: '' },
cancelable: true,
}));
beforeEach(() => {
- device.current = { isMobile: false, isTablet: true, isDesktop: false };
+ device.current = { isMobile: true, isCompact: false, isDesktop: false };
useSettingsStore.setState({ interfaceLayout: 'default' } as never);
useUIStore.setState({ sidebarOpen: true });
});
diff --git a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
index f8162531e..7122c0fe4 100644
--- a/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
+++ b/components/mail/__tests__/mail-app-thread-expand-mark-read.test.tsx
@@ -12,8 +12,9 @@ import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
// The `x` shortcut expands the selected conversation and marks it read. It has
// to follow markAsReadDelay like the reading pane, and a delayed mark must not
// land once the user has moved on (`j`/`k`) to a message outside it. The
-// default layout shows the conversation in the reading pane instead, so this
-// runs where the list still unfolds it: Mountain View on a tablet.
+// default layout shows the conversation in the reading pane instead, and
+// Mountain View's desktop shell has Gmail's `x` (select), so this runs where
+// the list still unfolds it: Mountain View on a phone with a keyboard.
vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => null }));
@@ -34,10 +35,10 @@ vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMa
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
- useDeviceDetection: () => ({ isMobile: false, isTablet: true, isDesktop: false }),
+ useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
useMediaQuery: () => false,
- useIsDesktop: () => true,
- useIsMobile: () => false,
+ useIsDesktop: () => false,
+ useIsMobile: () => true,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
diff --git a/components/mail/__tests__/mobile-send-view.test.tsx b/components/mail/__tests__/mobile-send-view.test.tsx
index dd6f3e2b0..3c9d9f4b5 100644
--- a/components/mail/__tests__/mobile-send-view.test.tsx
+++ b/components/mail/__tests__/mobile-send-view.test.tsx
@@ -43,7 +43,7 @@ vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMa
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
vi.mock('@/hooks/use-media-query', () => ({
- useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
+ useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
@@ -89,7 +89,7 @@ afterEach(() => {
});
/** On a phone only one of list and reading pane shows; the other gets max-md:hidden. */
-const listHidden = () => screen.getByText('message list').closest('.max-md\\:hidden') !== null;
+const listHidden = () => screen.getByText('message list').closest('.max-desk\\:hidden') !== null;
describe('sending from a phone', () => {
it('goes back to the message list after sending a new message', async () => {
diff --git a/components/mail/__tests__/mobile-tour-end.test.tsx b/components/mail/__tests__/mobile-tour-end.test.tsx
index c059d5839..99ee37297 100644
--- a/components/mail/__tests__/mobile-tour-end.test.tsx
+++ b/components/mail/__tests__/mobile-tour-end.test.tsx
@@ -67,9 +67,9 @@ vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountI
vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
-const device = vi.hoisted(() => ({ isMobile: true, isTablet: false }));
+const device = vi.hoisted(() => ({ isMobile: true, isCompact: false }));
vi.mock('@/hooks/use-media-query', () => ({
- useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: device.isTablet, isDesktop: !device.isMobile && !device.isTablet }),
+ useDeviceDetection: () => ({ isMobile: device.isMobile, isCompact: device.isCompact, isDesktop: !device.isMobile }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
@@ -112,13 +112,13 @@ beforeEach(() => {
});
afterEach(() => {
device.isMobile = true;
- device.isTablet = false;
+ device.isCompact = false;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
-/** On a phone only one of list and reading pane shows; the other gets max-md:hidden. */
-const listHidden = () => screen.getByText('message list').closest('.max-md\\:hidden') !== null;
+/** On a phone only one of list and reading pane shows; the other gets max-desk:hidden. */
+const listHidden = () => screen.getByText('message list').closest('.max-desk\\:hidden') !== null;
describe('Mail after the tour on a phone', () => {
it('shows the list when it opens with the reader left on and nothing to read', () => {
@@ -138,16 +138,17 @@ describe('Mail after the tour on a phone', () => {
expect(document.querySelector('[data-tour="compose-button"]')).toBeNull();
});
- // mail-27: a tablet drew the header's ⋮ and the list's ⋮ one above the
- // other, with the same entries; the header's one has them all now.
- it('draws the list ⋮ on a desktop but not on a tablet, under the header ⋮', () => {
+ // mail-27 kept the tablet from drawing the header's ⋮ and the list's ⋮ one
+ // above the other. The tablet widths are a compact desktop now, which has
+ // no phone header: the list's ⋮ is the only one there.
+ it('draws the list ⋮ on a desktop, compact or full', () => {
device.isMobile = false;
const view = render( );
expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
- device.isTablet = true;
+ device.isCompact = true;
view.rerender( );
- expect(screen.queryByTestId('mail-folder-menu')).toBeNull();
+ expect(screen.queryByTestId('mail-folder-menu')).not.toBeNull();
});
it('keeps an open composer in view when the window narrows to a phone', async () => {
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index f6a40413d..b02ee3be3 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -54,12 +54,12 @@ import { CalendarEventNotificationToaster } from "@/components/layout/calendar-e
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInPlace } from '@/stores/auth-store';
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
-import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
+import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { useOfflineStore } from "@/lib/offline/state";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
-import { useUIStore, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
-import { atLeast, useDeviceDetection, useMediaQuery } from "@/hooks/use-media-query";
+import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
+import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
@@ -92,7 +92,7 @@ import {
} from "@/components/error";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
-import { MailFolderMenu, MailFolderMenuItems, MailSortSubMenu } from "@/components/email/mail-list-menus";
+import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -272,7 +272,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The list is the category tabs' panel while the tabs are shown.
const messageListPanelId = useId();
const messageListPanelProps = useMessageListPanelProps(messageListPanelId);
- const mailLayout = useEffectiveMailLayout();
+ const mailLayout = useDrawnMailLayout();
const interfaceLayout = useSettingsStore((state) => state.interfaceLayout);
// Phones present search full-screen from the header field rather than
// giving it a permanent second bar under the header.
@@ -413,9 +413,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Window, selection and navigation events for plugins are fired app-wide
// by components/providers/plugin-app-events.tsx.
- // Mobile/tablet responsive hooks
- const { isMobile, isTablet } = useDeviceDetection();
- const isShortWindow = useMediaQuery('(max-height: 32rem)');
+ // Phone, compact desktop or desktop (classifyWidth). A phone on its side is
+ // a compact desktop: the rail at the side costs no height.
+ const { isMobile, isCompact } = useDeviceDetection();
const isEmbedded = useIsEmbedded();
// A message or conversation link shows the reader from the first frame, as
// the page skeleton draws it, in its loading state until the link has been
@@ -436,7 +436,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const [cursorOnly, setCursorOnly] = useState(false);
// Desktop lifts search into a global header. Mobile keeps its native
// one-pane flow, with Mountain View's own app chrome.
- const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isTablet && !isEmbedded;
+ const mvShell = interfaceLayout === 'mountain-view' && !isMobile && !isEmbedded;
const mvMobile = interfaceLayout === 'mountain-view' && isMobile && !isEmbedded;
// Mountain View reads a thread as one conversation in the viewer, on the
// phone as on the desktop, so both show the same order and details. (#1224)
@@ -454,13 +454,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// after a split closes and reopens at a different size.
const clampEmailListWidth = (width: number) =>
paneWidth !== null ? Math.min(width, Math.max(220, Math.floor(paneWidth * 0.55))) : width;
- const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
+ const { activeView, sidebarOpen, setSidebarOpen, setActiveView, sidebarWidth, emailListWidth, emailListHeight, setSidebarWidth, setEmailListWidth, setEmailListHeight, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth, resetEmailListHeight } = useUIStore(useShallow((state) => ({
activeView: state.activeView,
sidebarOpen: state.sidebarOpen,
setSidebarOpen: state.setSidebarOpen,
setActiveView: state.setActiveView,
- tabletListVisible: state.tabletListVisible,
- setTabletListVisible: state.setTabletListVisible,
sidebarWidth: state.sidebarWidth,
emailListWidth: state.emailListWidth,
emailListHeight: state.emailListHeight,
@@ -468,7 +466,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setEmailListWidth: state.setEmailListWidth,
setEmailListHeight: state.setEmailListHeight,
persistColumnWidths: state.persistColumnWidths,
- sidebarCollapsed: state.sidebarCollapsed,
+ // On a compact desktop the folders show as icons until opened.
+ sidebarCollapsed: sidebarFolded(state, isCompact),
resetSidebarWidth: state.resetSidebarWidth,
resetEmailListWidth: state.resetEmailListWidth,
resetEmailListHeight: state.resetEmailListHeight,
@@ -1034,9 +1033,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (isMobile) {
setActiveView("list");
}
- if (isTablet) {
- setTabletListVisible(true);
- }
},
onReply: () => {
if (isScheduledView) return;
@@ -1280,7 +1276,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, isTablet, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
+ }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
@@ -2170,7 +2166,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setFullscreenReading(true);
}
if (isMobile) setActiveView('viewer');
- if (isTablet) setTabletListVisible(false);
if (link.reply) {
// A notification's Reply. The branch above may have skipped
// selecting, and the reply has to wait for the message to be open.
@@ -2210,7 +2205,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A shared account's conversation needs its folder open: the open
// message is acted on in the account of the folder on screen.
if (email && link.ownerAccountId) await showInOwningFolder(activeClient, email);
- if (isTablet) setTabletListVisible(false);
}
} finally {
setLoadingEmail(false);
@@ -2341,12 +2335,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedEmail?.id, selectedEmail?.sourceClientAccountId, selectedEmail?.sourceAccountId]);
- // Opening a message hides the list on tablet, but plenty of paths clear the
- // reading pane without going through a "back" handler (delete/move/archive,
- // a failed fetch, history restore, sending the open draft). Bring the list
- // back once there is nothing left to read, so tablet never ends up on an
- // empty reading pane with only the bottom bar.
- const readingPaneEmpty = !showComposer && !conversationThread && !selectedEmail && !isLoadingEmail;
// The phone conversation's Report spam / Not spam, as in the list: not on
// your own outgoing mail.
const conversationRole = mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
@@ -2365,9 +2353,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const folderMenuMarkRead = useCallback(() => folderMenuLatest.current.markRead(), []);
const folderMenuMarkAllRead = useCallback(() => folderMenuLatest.current.markAllRead(), []);
const folderMenuEmpty = useCallback(() => folderMenuLatest.current.empty(), []);
- useEffect(() => {
- if (readingPaneEmpty && !tabletListVisible) setTabletListVisible(true);
- }, [readingPaneEmpty, tabletListVisible, setTabletListVisible]);
// Handle mark-as-read with delay based on settings
useEffect(() => {
@@ -2437,14 +2422,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A composer opened from the drawer (a plugin's birthday greeting, a contact)
// must not stay under it.
useEffect(() => {
- if (showComposer && (isMobile || isTablet)) setSidebarOpen(false);
+ if (showComposer && isMobile) setSidebarOpen(false);
// Only when a composer opens: the drawer may be opened over one later.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showComposer]);
- // Lock body scroll when sidebar is open on mobile/tablet
+ // Lock body scroll when sidebar is open on a phone
useEffect(() => {
- if ((isMobile || isTablet) && sidebarOpen) {
+ if (isMobile && sidebarOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
@@ -2452,7 +2437,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return () => {
document.body.style.overflow = '';
};
- }, [isMobile, isTablet, sidebarOpen]);
+ }, [isMobile, sidebarOpen]);
// Move focus into the sidebar when it opens as an overlay, and back to the
// toggle when it closes, so keyboard and screen reader users end up where the
@@ -2464,7 +2449,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const sidebarContainerRef = useRef(null);
const sidebarWasOpen = useRef(false);
useEffect(() => {
- const isOverlay = isMobile || isTablet;
+ const isOverlay = isMobile;
if (isOverlay && sidebarOpen) {
sidebarWasOpen.current = true;
const frame = requestAnimationFrame(() => {
@@ -2482,7 +2467,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const active = document.activeElement;
if (active && active !== document.body) return;
appRootRef.current?.querySelector('[data-sidebar-toggle]')?.focus();
- }, [isMobile, isTablet, sidebarOpen]);
+ }, [isMobile, sidebarOpen]);
// The message a composer session answers, pinned when the session starts.
// Mountain View keeps its compose window open while other mail is read
@@ -2933,8 +2918,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const wasOpen = useEmailStore.getState().selectedEmail?.id === email.id;
const restored = await cancelScheduledForEdit(email);
if (restored === undefined) return;
- // The store closed it in the reader; on a phone or tablet the list comes back.
- if (wasOpen && (isMobile || isTablet)) handleMobileBack();
+ // The store closed it in the reader; on a phone the list comes back.
+ if (wasOpen && isMobile) handleMobileBack();
toast.success(
tViewer('send_cancelled'),
t('notifications.moved_to_mailbox', { mailbox: t('sidebar.mailboxes.drafts') }),
@@ -3356,7 +3341,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Outlook. With the conversation in the reading pane that is the next
// conversation down the list (the one above at the end), as its row opens it.
const returnsToList = () => returnToListAfterAction(
- !fullscreenReading && listBesideReader({ mailLayout, interfaceLayout, isMobile, isTablet }),
+ !fullscreenReading && listBesideReader({ mailLayout, interfaceLayout, isMobile }),
);
const leaveIfOpen = (email: Email) => {
const { selectedEmail: open, emails: rows, listRows } = useEmailStore.getState();
@@ -3755,10 +3740,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
if (viewingClient) {
// Keep an active search applied when switching folders (#553), unless
// the user opted to reset search on folder change. The store actions
@@ -3809,10 +3790,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
if (client) await fetchScheduledEmails(client);
return;
}
@@ -3829,10 +3806,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
const isSameView = captureViewToken();
const populated = await buildPopulatedUnifiedAccounts();
@@ -3868,10 +3841,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setSidebarOpen(false);
setActiveView("list");
}
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
const isSameView = captureViewToken();
const populated = await buildPopulatedUnifiedAccounts();
@@ -3907,12 +3876,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("list");
}
- // On tablet, show the list again
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
-
if (client) {
// If there's an active search, re-run it in the new mailbox unless the
// user opted to reset search on folder change. Advanced filters must go
@@ -3940,12 +3903,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("list");
}
- // On tablet, show the list again
- if (isTablet) {
- setTabletListVisible(true);
- setSidebarOpen(false);
- }
-
if (client) {
await fetchEmails(client);
}
@@ -4869,7 +4826,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
- const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
+ const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile;
// Under Mountain View on desktop the composer floats over the list in a
// docked window instead of taking the reading pane.
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
@@ -4881,13 +4838,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
- const shouldCollapseListPane = (isTablet && ((!tabletListVisible && !readingPaneEmpty) || deepLinkPane !== null)) || (!isMobile && isFocusedMailLayout && hasViewerContent);
+ const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
- // A phone on its side counts as a tablet, which keeps the bottom bar. In a window
- // this short the bar takes a sixth of it and leaves a few lines, so it goes where
- // a phone held upright hides it too: over a composer, or a message filling the
- // window. With the list beside the message it is the only way to the other apps.
- const shortWindowHidesTabBar = isShortWindow && ((showComposer && !floatingComposer) || (hasViewerContent && shouldCollapseListPane));
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Where the empty reading pane shows beside the list, it holds the
// first-run tips (mail-15) and a selection's actions (mail-10).
@@ -4965,11 +4917,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setActiveView("viewer");
}
- // On tablet, hide the list to maximize viewer space
- if (isTablet) {
- setTabletListVisible(false);
- }
-
// Fetch the full content
try {
const source = resolveEmailBlobContext(listEmail ?? null, client);
@@ -5060,9 +5007,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setConversationEmails([]);
}
selectEmail(null);
- if (isTablet) {
- setTabletListVisible(true);
- }
setActiveView("list");
};
@@ -5243,7 +5187,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
// The window widened into Pro's width, which takes this page over:
// the draft goes on in a compose tab.
- if (!isEmbedded && useSettingsStore.getState().proInterface && window.matchMedia(atLeast(1024)).matches) {
+ if (!isEmbedded && useSettingsStore.getState().proInterface && window.matchMedia(atLeast(PHONE_BELOW)).matches) {
useProTabStore.getState().openComposeTab({
sessionId: composerSessionId + 1,
mode: data.mode,
@@ -5360,7 +5304,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Desktop Navigation Rail (hidden when embedded inside Pro shell, and
under Mountain View, whose header carries the app grid instead) */}
- {!isMobile && !isTablet && !isEmbedded && !mvShell && (
+ {!isMobile && !isEmbedded && !mvShell && (
setSidebarOpen(false)}
/>
@@ -5404,8 +5348,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
/* On mobile/tablet the sidebar is only slid off-screen, so without
`inert` it stays in the accessibility tree and screen readers can
reach a menu the Toggle menu button reports as collapsed (#721). */
- inert={(isMobile || isTablet) && !sidebarOpen}
- onKeyDown={(isMobile || isTablet) && sidebarOpen ? (e) => {
+ inert={isMobile && !sidebarOpen}
+ onKeyDown={isMobile && sidebarOpen ? (e) => {
if (e.key === 'Escape') setSidebarOpen(false);
} : undefined}
className={cn(
@@ -5413,10 +5357,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The z-index is for the phone drawer. On a desktop under Mountain
// View it only has to lift the hover peek over the list: at 40 the
// compose window, drawn later at the same level, stays on top.
- mvShell && "lg:z-40",
+ mvShell && "desk:z-40",
!isResizing && "transition-[width] duration-300",
isEmbedded
- ? (isMobile || isTablet
+ ? (isMobile
? cn(
"absolute inset-y-0 start-0 w-72 pt-[var(--app-top-inset)]",
"transform transition-transform duration-300 ease-in-out",
@@ -5429,16 +5373,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mobile/Tablet: fixed overlay
// start-0 rather than left-0: in RTL the drawer belongs on
// the right, matching the hamburger that opens it.
- "max-lg:fixed max-lg:inset-y-0 max-lg:start-0 max-lg:w-72 max-lg:pt-[var(--app-top-inset)]",
- "max-lg:transform max-lg:transition-transform max-lg:duration-300 max-lg:ease-in-out",
- !sidebarOpen && "max-lg:-translate-x-full max-lg:rtl:translate-x-full",
+ "max-desk:fixed max-desk:inset-y-0 max-desk:start-0 max-desk:w-72 max-desk:pt-[var(--app-top-inset)]",
+ "max-desk:transform max-desk:transition-transform max-desk:duration-300 max-desk:ease-in-out",
+ !sidebarOpen && "max-desk:-translate-x-full max-desk:rtl:translate-x-full",
// Desktop: normal flow
- "lg:relative lg:translate-x-0"
+ "desk:relative desk:translate-x-0"
),
inlineApp && "hidden",
fullscreenReading && "hidden"
)}
- style={!isMobile && !isTablet ? { width: sidebarCollapsed ? (mvShell ? 72 : 48) : sidebarWidth } : undefined}
+ style={!isMobile ? { width: sidebarCollapsed ? (mvShell ? 72 : 48) : sidebarWidth } : undefined}
>
-
+
{/* Email List - full width on mobile, fixed width/height on tablet/desktop */}
{ close(); void handleManualRefresh(); }}
/>
- {/* A tablet shows the list's own ⋮ nowhere else (see below). */}
- {isTablet && }
handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
@@ -5664,10 +5606,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
/>
- {/* The folder's actions, as the Mountain View list ⋮ has them. A
- tablet has them in the header's ⋮ right above, and two ⋮
- stacked read as one menu twice (mail-27). */}
- {!isTablet && }
+ />
{/* Plugin buttons (top-bar-actions). On phones they sit in
the header's overflow menu instead (MobileHeader). */}
{!isMobile && (
@@ -5886,7 +5826,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Floating Compose Button - on desktop the folder sidebar has
Compose at its top instead (and Mountain View its rail), so the
button no longer sits on the list's rows. */}
- {!mvShell && (isMobile || isTablet || isEmbedded) && {
void withComposeGate('compose', {}, () => {
startFreshComposerSession();
@@ -5914,7 +5854,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Email list resize handle (desktop only) */}
- {!isMobile && !isTablet && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && (
+ {!isMobile && !isFocusedMailLayout && !isHorizontalMailLayout && !shouldHideViewerPane && !fullscreenReading && (
)}
- {!isMobile && !isTablet && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && !fullscreenReading && (
+ {!isMobile && isHorizontalMailLayout && !shouldHideHorizontalViewerPane && !fullscreenReading && (
{/* Inline Composer - shown in viewer pane.
@@ -6196,8 +6136,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
- {/* Bottom Navigation - mobile and tablet (hidden when embedded) */}
- {(isMobile || isTablet) && mobileView !== "viewer" && !shortWindowHidesTabBar && !isEmbedded && (
+ {/* Bottom Navigation - phones (hidden when embedded) */}
+ {isMobile && mobileView !== "viewer" && !isEmbedded && (
}
+ {!mvShell && !isMobile && !isEmbedded && !inlineApp && !fullscreenReading &&
}
{/* Keyboard Shortcuts Modal */}
diff --git a/components/settings/__tests__/settings-phone-deep-link.test.tsx b/components/settings/__tests__/settings-phone-deep-link.test.tsx
index 891eabe6d..e7f7190a7 100644
--- a/components/settings/__tests__/settings-phone-deep-link.test.tsx
+++ b/components/settings/__tests__/settings-phone-deep-link.test.tsx
@@ -17,10 +17,11 @@ vi.mock('next-intl', () => ({
}));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
atLeast: (await importOriginal()).atLeast,
+ PHONE_BELOW: (await importOriginal()).PHONE_BELOW,
useIsDesktop: () => media.desktop,
useIsMobile: () => true,
useMediaQuery: () => false,
- useDeviceDetection: () => ({ isMobile: true, isTablet: false, isDesktop: false }),
+ useDeviceDetection: () => ({ isMobile: true, isCompact: false, isDesktop: false }),
}));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index d7f9ecdf9..3a6364833 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -87,7 +87,7 @@ import {
ARCHIVE_ACTION_SEARCH_PATHS,
} from '@/lib/settings-search';
import { useAccountStore } from '@/stores/account-store';
-import { useMediaQuery, atLeast } from '@/hooks/use-media-query';
+import { useMediaQuery, atLeast, PHONE_BELOW } from '@/hooks/use-media-query';
import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments';
import { useDocumentTitle } from '@/hooks/use-document-title';
import { MvTopBar } from "@/components/layout/mv-top-bar";
@@ -505,7 +505,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
if (typeof window === 'undefined') return;
if (!mobileShowContent) return;
// The first hydration pass reports a phone layout on every screen.
- if (!isEmbedded && window.matchMedia(atLeast(1024)).matches) return;
+ if (!isEmbedded && window.matchMedia(atLeast(PHONE_BELOW)).matches) return;
// A page opened straight from a link has no list below it yet: turn the
// entry it arrived on into the list, then push the page on top. Inside
@@ -1120,6 +1120,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
data-pane="sidebar"
className={cn(
"border-e border-border bg-secondary flex flex-col",
+ // A compact desktop keeps more of its width for the page.
+ "max-lg:max-w-52",
!isResizing && "transition-[width] duration-300"
)}
style={{ width: `${settingsSidebarWidth}px` }}
diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx
index 2c6d3412f..d56038963 100644
--- a/components/tour/__tests__/tour.test.tsx
+++ b/components/tour/__tests__/tour.test.tsx
@@ -235,7 +235,7 @@ describe('tour text on a phone', () => {
const step = tour.steps[0] as { phoneDescriptionKey?: string };
step.phoneDescriptionKey = 'one_desc_phone';
const spy = vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({
- matches: query === '(width < 48rem)', media: query, onchange: null,
+ matches: query === '(width < 37.5rem)', media: query, onchange: null,
addEventListener: () => {}, removeEventListener: () => {}, addListener: () => {}, removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList);
diff --git a/hooks/__tests__/plugin-ui-hooks.test.tsx b/hooks/__tests__/plugin-ui-hooks.test.tsx
index 3f81cc975..f591d4f5e 100644
--- a/hooks/__tests__/plugin-ui-hooks.test.tsx
+++ b/hooks/__tests__/plugin-ui-hooks.test.tsx
@@ -30,7 +30,7 @@ const EMAIL = { id: 'm1', mailboxIds: { inbox: true }, keywords: {} } as unknown
beforeEach(() => {
clearAllHooks();
- useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isTablet: false, isDesktop: false });
+ useUIStore.setState({ activeView: 'list', sidebarOpen: false, sidebarCollapsed: false, isMobile: true, isCompact: false, isDesktop: false });
});
describe('layout hooks', () => {
@@ -45,11 +45,13 @@ describe('layout hooks', () => {
useUIStore.getState().setSidebarCollapsed(true);
useUIStore.getState().setDeviceType(false, false, true);
useUIStore.getState().setDeviceType(false, false, true);
+ // A compact desktop is "tablet" to plugins.
+ useUIStore.getState().setDeviceType(false, true, true);
expect(view).toHaveBeenCalledWith({ view: 'viewer' });
expect(toggle).toHaveBeenCalledWith({ open: true });
expect(collapse).toHaveBeenCalledWith({ collapsed: true });
- expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }]]);
+ expect(device.mock.calls).toEqual([[{ deviceType: 'desktop' }], [{ deviceType: 'tablet' }]]);
});
it('reports Back on the phone', () => {
diff --git a/hooks/__tests__/use-email-drag-routing.test.tsx b/hooks/__tests__/use-email-drag-routing.test.tsx
index e3757d946..2ef24d0eb 100644
--- a/hooks/__tests__/use-email-drag-routing.test.tsx
+++ b/hooks/__tests__/use-email-drag-routing.test.tsx
@@ -648,7 +648,7 @@ describe('ZIP failure handling and retry', () => {
describe('message row drag hover wiring', () => {
beforeEach(() => {
useSettingsStore.setState({ interfaceLayout: 'default', mailLayout: 'split', density: 'regular', hoverActions: ['delete'] });
- useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useUIStore.setState({ isMobile: false, isCompact: false, isDesktop: true });
});
function renderRows(emails: Email[]) {
diff --git a/hooks/__tests__/use-media-query-breakpoints.test.ts b/hooks/__tests__/use-media-query-breakpoints.test.ts
index 6e3472565..bc68b3934 100644
--- a/hooks/__tests__/use-media-query-breakpoints.test.ts
+++ b/hooks/__tests__/use-media-query-breakpoints.test.ts
@@ -1,15 +1,26 @@
/**
* The scripts' breakpoints follow the browser's default text size, as the
- * CSS breakpoints (Tailwind's rem) do. With a 32px default a 1440px window is
- * 45rem wide: the CSS draws the phone layout, so the scripts must pick it too,
- * or the mail screen showed only the reading pane with no list (LV-11).
+ * CSS breakpoints (Tailwind's rem) do. With a 32px default a 1100px window is
+ * about 34rem wide: the CSS draws the phone layout, so the scripts must pick
+ * it too, or the mail screen showed only the reading pane with no list (LV-11).
+ *
+ * The layout classes: a phone below 600px, a compact desktop (the folder
+ * sidebar folded to icons) up to 1024px, the full desktop above. A window
+ * snapped to half a screen and a laptop at 150 % (911px) are desktops
+ * (usability test 2026-11-04, mail-28 and settings-16).
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
+import { createElement, type ReactNode } from 'react';
import { renderHook } from '@testing-library/react';
-import { useIsMobile, useIsDesktop, narrowerThan, atLeast } from '../use-media-query';
+import {
+ useIsMobile, useIsDesktop, useIsFullDesktop, useDeviceDetection,
+ classifyWidth, narrowerThan, atLeast,
+} from '../use-media-query';
+import { useSplitPanesFit } from '../use-split-panes-fit';
+import { PaneSizeContext } from '../use-pane-size';
/** A window `px` wide whose default text size is `rootPx`. */
-function viewport(px: number, rootPx: number) {
+function viewport(px: number, rootPx = 16) {
vi.stubGlobal('matchMedia', (query: string) => {
const m = /\(width (<|>=) ([\d.]+)rem\)/.exec(query);
const matches = m ? (m[1] === '<' ? px < Number(m[2]) * rootPx : px >= Number(m[2]) * rootPx) : false;
@@ -17,23 +28,87 @@ function viewport(px: number, rootPx: number) {
});
}
+/** Renders inside a Pro pane `width` px wide. */
+const inPane = (width: number) => ({ children }: { children: ReactNode }) =>
+ createElement(PaneSizeContext.Provider, { value: width }, children);
+
afterEach(() => vi.unstubAllGlobals());
describe('breakpoints', () => {
it('writes the queries in rem, as Tailwind does', () => {
- expect(narrowerThan(768)).toBe('(width < 48rem)');
+ expect(narrowerThan(600)).toBe('(width < 37.5rem)');
expect(atLeast(1024)).toBe('(width >= 64rem)');
});
it('is a desktop at 1440px with the default 16px text', () => {
- viewport(1440, 16);
+ viewport(1440);
expect(renderHook(() => useIsDesktop()).result.current).toBe(true);
expect(renderHook(() => useIsMobile()).result.current).toBe(false);
});
- it('is a phone at 1440px when the browser text size is 32px', () => {
- viewport(1440, 32);
+ it('is a phone at 1100px when the browser text size is 32px', () => {
+ viewport(1100, 32);
expect(renderHook(() => useIsDesktop()).result.current).toBe(false);
expect(renderHook(() => useIsMobile()).result.current).toBe(true);
});
});
+
+describe('classifyWidth', () => {
+ it('draws the phone layout only below 600px', () => {
+ expect(classifyWidth(390)).toEqual({ isMobile: true, isCompact: false, isDesktop: false });
+ expect(classifyWidth(599)).toEqual({ isMobile: true, isCompact: false, isDesktop: false });
+ expect(classifyWidth(600)).toEqual({ isMobile: false, isCompact: true, isDesktop: true });
+ });
+
+ it('draws a compact desktop up to the full desktop at 1024px', () => {
+ for (const w of [640, 800, 853, 911, 1023]) {
+ expect(classifyWidth(w)).toEqual({ isMobile: false, isCompact: true, isDesktop: true });
+ }
+ expect(classifyWidth(1024)).toEqual({ isMobile: false, isCompact: false, isDesktop: true });
+ expect(classifyWidth(1366)).toEqual({ isMobile: false, isCompact: false, isDesktop: true });
+ });
+});
+
+describe('the window', () => {
+ it('is a desktop at half a screen and at 150 % on a 1366px laptop', () => {
+ for (const w of [640, 800, 911]) {
+ viewport(w);
+ expect(renderHook(() => useIsDesktop()).result.current).toBe(true);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(false);
+ expect(renderHook(() => useIsFullDesktop()).result.current).toBe(false);
+ }
+ });
+
+ it('is a phone below 600px', () => {
+ viewport(599);
+ expect(renderHook(() => useIsMobile()).result.current).toBe(true);
+ expect(renderHook(() => useIsDesktop()).result.current).toBe(false);
+ });
+
+ it('puts the list beside the reader from 840px', () => {
+ viewport(839);
+ expect(renderHook(() => useSplitPanesFit()).result.current).toBe(false);
+ viewport(911);
+ expect(renderHook(() => useSplitPanesFit()).result.current).toBe(true);
+ });
+});
+
+describe('a Pro pane', () => {
+ it('classifies by its own width, not the window', () => {
+ viewport(1920);
+ expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(500) }).result.current)
+ .toEqual({ isMobile: true, isCompact: false, isDesktop: false });
+ expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(700) }).result.current)
+ .toEqual({ isMobile: false, isCompact: true, isDesktop: true });
+ expect(renderHook(() => useDeviceDetection(), { wrapper: inPane(1100) }).result.current)
+ .toEqual({ isMobile: false, isCompact: false, isDesktop: true });
+ });
+
+ it('answers the single hooks by its width too', () => {
+ viewport(1920);
+ expect(renderHook(() => useIsMobile(), { wrapper: inPane(599) }).result.current).toBe(true);
+ expect(renderHook(() => useIsDesktop(), { wrapper: inPane(600) }).result.current).toBe(true);
+ expect(renderHook(() => useIsFullDesktop(), { wrapper: inPane(900) }).result.current).toBe(false);
+ expect(renderHook(() => useSplitPanesFit(), { wrapper: inPane(800) }).result.current).toBe(false);
+ });
+});
diff --git a/hooks/use-effective-mail-layout.ts b/hooks/use-effective-mail-layout.ts
index 312c8eb41..51ac36203 100644
--- a/hooks/use-effective-mail-layout.ts
+++ b/hooks/use-effective-mail-layout.ts
@@ -1,8 +1,23 @@
"use client";
import { effectiveMailLayout, useSettingsStore, type MailLayout } from "@/stores/settings-store";
+import { useSplitPanesFit } from "@/hooks/use-split-panes-fit";
/** The mail layout in force: the saved one, or `focus` under Mountain View. */
export function useEffectiveMailLayout(): MailLayout {
return useSettingsStore((state) => effectiveMailLayout(state.mailLayout, state.interfaceLayout, state.simpleMode));
}
+
+/**
+ * The layout the mail screen draws now: the one in force, except that a
+ * window (or Pro pane) too narrow for the list and the reading pane side by
+ * side opens a message in the list's place, as the focused layout does. The
+ * list keeps its rows as the chosen layout draws them (useListRowShape).
+ */
+export function drawnMailLayout(layout: MailLayout, splitPanesFit: boolean): MailLayout {
+ return layout === 'split' && !splitPanesFit ? 'focus' : layout;
+}
+
+export function useDrawnMailLayout(): MailLayout {
+ return drawnMailLayout(useEffectiveMailLayout(), useSplitPanesFit());
+}
diff --git a/hooks/use-list-row-shape.ts b/hooks/use-list-row-shape.ts
index f0c64ccd3..719b947d6 100644
--- a/hooks/use-list-row-shape.ts
+++ b/hooks/use-list-row-shape.ts
@@ -12,8 +12,9 @@ export interface ListRowShape {
* How the message list draws its rows.
*
* The focused list and the bottom reading pane both give the list the full
- * width, so their rows run on one line. Phones fall back to stacked rows, and
- * so do tablets under the bottom pane, which they draw as the split layout.
+ * width, so their rows run on one line. Phones fall back to stacked rows.
+ * A window too narrow for the split layout's two panes keeps its stacked rows
+ * (useDrawnMailLayout): the list is as narrow as the window.
* Columns are a table on top of the one-line rows (#262); Mountain View rows
* are shaped after Gmail and never take them.
*/
@@ -22,8 +23,7 @@ export function useListRowShape(): ListRowShape {
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
const listColumns = useSettingsStore((state) => state.listColumns);
const isMobile = useUIStore((state) => state.isMobile);
- const isTablet = useUIStore((state) => state.isTablet);
- const oneLine = !isMobile && (mailLayout === 'focus' || (mailLayout === 'horizontal' && !isTablet));
+ const oneLine = !isMobile && (mailLayout === 'focus' || mailLayout === 'horizontal');
return { oneLine, columns: oneLine && listColumns && !mountainView };
}
diff --git a/hooks/use-media-query.ts b/hooks/use-media-query.ts
index fc933a4a2..9e8216736 100644
--- a/hooks/use-media-query.ts
+++ b/hooks/use-media-query.ts
@@ -14,6 +14,35 @@ const BREAKPOINTS = {
"2xl": 1536,
} as const;
+/**
+ * The widths that decide the layout (`desk:` in app/globals.css is the first).
+ * Below PHONE_BELOW the phone layout: one pane at a time, a bottom tab bar and
+ * the folders in a drawer. From there up the desktop layout, with the app rail
+ * and the folder sidebar beside the list. Below FULL_DESKTOP that desktop is
+ * compact: the folder sidebar folds to its icons, and calendar, contacts and
+ * files open their sidebars over the page. A half-screen window and a laptop
+ * at 150 % (911px) are desktops, not tablets (usability test 2026-11-04).
+ */
+export const PHONE_BELOW = 600;
+export const FULL_DESKTOP = BREAKPOINTS.lg;
+export interface DeviceClass {
+ /** Phone layout. */
+ isMobile: boolean;
+ /** Desktop layout narrower than the full desktop: the sidebar folds to icons. */
+ isCompact: boolean;
+ /** Desktop layout, compact or full. */
+ isDesktop: boolean;
+}
+
+/** The layout a window or pane `width` CSS pixels wide gets. */
+export function classifyWidth(width: number): DeviceClass {
+ return {
+ isMobile: width < PHONE_BELOW,
+ isCompact: width >= PHONE_BELOW && width < FULL_DESKTOP,
+ isDesktop: width >= PHONE_BELOW,
+ };
+}
+
const getMediaQueryServerSnapshot = () => false;
/**
@@ -61,23 +90,18 @@ export function useMediaQuery(query: string): boolean {
* When the Pro shell renders a page inside a (possibly split) pane, that pane
* publishes its measured width via `PaneSizeContext`. Inner pages should
* branch their layout against the pane width - not the full viewport - so a
- * narrow pane gets the mobile/tablet layout instead of overflowing.
+ * narrow pane gets the phone or compact layout instead of overflowing.
*
* Returns `null` when no pane size is published, signalling the caller to
* fall back to `window.matchMedia`.
*/
-function classifyPane(paneWidth: number | null) {
- if (paneWidth === null) return null;
- return {
- isMobile: paneWidth < BREAKPOINTS.md,
- isTablet: paneWidth >= BREAKPOINTS.md && paneWidth < BREAKPOINTS.lg,
- isDesktop: paneWidth >= BREAKPOINTS.lg,
- };
+function classifyPane(paneWidth: number | null): DeviceClass | null {
+ return paneWidth === null ? null : classifyWidth(paneWidth);
}
/**
- * Hook to detect device type and sync with UI store
- * Uses Tailwind breakpoints: mobile < 768px, tablet 768-1024px, desktop > 1024px
+ * Hook to detect the layout class (classifyWidth) and sync it with the UI
+ * store: phone below 600px, compact desktop to 1024px, full desktop above.
*
* When invoked inside a Pro pane, the returned values reflect the pane's
* width instead of the window's. The global UI store is NOT updated in that
@@ -85,35 +109,48 @@ function classifyPane(paneWidth: number | null) {
* and the store is meant to mirror the actual viewport for callers that read
* it directly (mobile navigation helpers etc.).
*/
-export function useDeviceDetection() {
- const { setDeviceType, isMobile, isTablet, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isTablet: state.isTablet, isDesktop: state.isDesktop })));
+export function useDeviceDetection(): DeviceClass {
+ const { setDeviceType, isMobile, isCompact, isDesktop } = useUIStore(useShallow((state) => ({ setDeviceType: state.setDeviceType, isMobile: state.isMobile, isCompact: state.isCompact, isDesktop: state.isDesktop })));
const paneWidth = usePaneSize();
const paneClassification = classifyPane(paneWidth);
- const isMobileQuery = useMediaQuery(narrowerThan(BREAKPOINTS.md));
- const isTabletQuery = useMediaQuery(`${atLeast(BREAKPOINTS.md)} and ${narrowerThan(BREAKPOINTS.lg)}`);
- const isDesktopQuery = useMediaQuery(atLeast(BREAKPOINTS.lg));
+ // Each class asks its own query, so the first hydration pass (every query
+ // false) reads as the full desktop and draws no folded sidebar for a frame.
+ const isMobileQuery = useMediaQuery(narrowerThan(PHONE_BELOW));
+ const isCompactQuery = useMediaQuery(`${atLeast(PHONE_BELOW)} and ${narrowerThan(FULL_DESKTOP)}`);
+ const isDesktopQuery = useMediaQuery(atLeast(PHONE_BELOW));
useEffect(() => {
if (paneClassification) return;
- setDeviceType(isMobileQuery, isTabletQuery, isDesktopQuery);
- }, [isMobileQuery, isTabletQuery, isDesktopQuery, setDeviceType, paneClassification]);
+ setDeviceType(isMobileQuery, isCompactQuery, isDesktopQuery);
+ }, [isMobileQuery, isCompactQuery, isDesktopQuery, setDeviceType, paneClassification]);
if (paneClassification) return paneClassification;
- return { isMobile, isTablet, isDesktop };
+ return { isMobile, isCompact, isDesktop };
+}
+
+/** The phone layout (below PHONE_BELOW), for the window or the Pro pane. */
+export function useIsMobile() {
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(narrowerThan(PHONE_BELOW));
+ return paneWidth !== null ? paneWidth < PHONE_BELOW : viewport;
+}
+
+/** The desktop layout, compact or full (from PHONE_BELOW up). */
+export function useIsDesktop() {
+ const paneWidth = usePaneSize();
+ const viewport = useMediaQuery(atLeast(PHONE_BELOW));
+ return paneWidth !== null ? paneWidth >= PHONE_BELOW : viewport;
}
/**
- * Convenience hooks for specific breakpoints
+ * The full desktop (from FULL_DESKTOP up), where sidebars sit beside the page
+ * at their own width. Below it calendar, contacts and files open theirs over
+ * the page.
*/
-export function useIsMobile() {
+export function useIsFullDesktop() {
const paneWidth = usePaneSize();
- const viewport = useMediaQuery(narrowerThan(BREAKPOINTS.md));
- return paneWidth !== null ? paneWidth < BREAKPOINTS.md : viewport;
+ const viewport = useMediaQuery(atLeast(FULL_DESKTOP));
+ return paneWidth !== null ? paneWidth >= FULL_DESKTOP : viewport;
}
-export function useIsDesktop() {
- const paneWidth = usePaneSize();
- const viewport = useMediaQuery(atLeast(BREAKPOINTS.lg));
- return paneWidth !== null ? paneWidth >= BREAKPOINTS.lg : viewport;
-}
diff --git a/hooks/use-return-to-list.ts b/hooks/use-return-to-list.ts
index 321dfeee0..0f12b1492 100644
--- a/hooks/use-return-to-list.ts
+++ b/hooks/use-return-to-list.ts
@@ -3,6 +3,7 @@
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
+import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
import { effectiveReturnToList, listBesideReader, returnToListSetByPolicy } from "@/lib/return-to-list";
/**
@@ -11,16 +12,16 @@ import { effectiveReturnToList, listBesideReader, returnToListSetByPolicy } from
* nobody has set it yet.
*/
export function useReturnToListAfterAction(): { checked: boolean; followsLayout: boolean } {
- const { isMobile, isTablet } = useDeviceDetection();
+ const { isMobile } = useDeviceDetection();
const value = useSettingsStore((s) => s.returnToListAfterAction);
const explicitSettings = useSettingsStore((s) => s.explicitSettings);
- const mailLayout = useSettingsStore((s) => s.mailLayout);
+ const mailLayout = useDrawnMailLayout();
const interfaceLayout = useSettingsStore((s) => s.interfaceLayout);
const simpleMode = useSettingsStore((s) => s.simpleMode);
// Re-read when the policy arrives or changes.
usePolicyStore((s) => s.policy);
const policySet = returnToListSetByPolicy();
- const besideReader = listBesideReader({ mailLayout, interfaceLayout, simpleMode, isMobile, isTablet });
+ const besideReader = listBesideReader({ mailLayout, interfaceLayout, simpleMode, isMobile });
return {
checked: effectiveReturnToList({ returnToListAfterAction: value, explicitSettings }, besideReader, policySet),
followsLayout: !policySet && !explicitSettings.includes("returnToListAfterAction"),
diff --git a/hooks/use-split-panes-fit.ts b/hooks/use-split-panes-fit.ts
new file mode 100644
index 000000000..52f6081d7
--- /dev/null
+++ b/hooks/use-split-panes-fit.ts
@@ -0,0 +1,33 @@
+"use client";
+
+import { useCallback, useSyncExternalStore } from "react";
+import { usePaneSize } from "@/hooks/use-pane-size";
+
+/**
+ * The narrowest window (or Pro pane) that puts the message list and the
+ * reading pane side by side, in CSS pixels at the default text size. Narrower,
+ * the split layout opens a message in the list's place, as the focused layout
+ * does (useDrawnMailLayout). The page skeleton's `boot-onepane` variant
+ * (app/boot-skeleton.css) uses the same width, 52.5rem.
+ */
+export const SPLIT_PANES_MIN = 840;
+
+const QUERY = `(width < ${SPLIT_PANES_MIN / 16}rem)`;
+const subscribe = (callback: () => void) => {
+ const mq = window.matchMedia(QUERY);
+ mq.addEventListener("change", callback);
+ return () => mq.removeEventListener("change", callback);
+};
+const getServerSnapshot = () => false;
+
+/**
+ * Whether the message list and the reading pane fit side by side. Asks for
+ * "too narrow", so a server render and the first hydration pass keep the
+ * two panes a desktop draws.
+ */
+export function useSplitPanesFit(): boolean {
+ const paneWidth = usePaneSize();
+ const getSnapshot = useCallback(() => window.matchMedia(QUERY).matches, []);
+ const tooNarrow = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
+ return paneWidth !== null ? paneWidth >= SPLIT_PANES_MIN : !tooNarrow;
+}
diff --git a/lib/__tests__/plugin-ui-kit.test.tsx b/lib/__tests__/plugin-ui-kit.test.tsx
index f11c92a9d..97a440f8a 100644
--- a/lib/__tests__/plugin-ui-kit.test.tsx
+++ b/lib/__tests__/plugin-ui-kit.test.tsx
@@ -157,10 +157,11 @@ describe('plugin UI kit components', () => {
});
it('makes sidebar rows as tall as folder rows on touch screens', () => {
- // The page sets --touch-row-min-h to 44px below lg, where folder rows
- // are max-lg:min-h-[44px]; the frame can't see the page's width itself.
- expect(source('components/layout/sidebar.tsx')).toContain('max-lg:min-h-[44px]');
- expect(source('app/globals.css')).toMatch(/@media \(width < 64rem\) \{\s*:root \{\s*--touch-row-min-h: 44px;/);
+ // The page sets --touch-row-min-h to 44px where folder rows are
+ // tap:min-h-[44px] (phones, touch screens below the full desktop); the
+ // frame can't see the page's width itself.
+ expect(source('components/layout/sidebar.tsx')).toContain('tap:min-h-[44px]');
+ expect(source('app/globals.css')).toMatch(/@media \(width < 37\.5rem\), \(width < 64rem\) and \(pointer: coarse\) \{\s*:root \{\s*--touch-row-min-h: 44px;/);
render( {}} />);
expect(screen.getByRole('button', { name: 'Anna' }).style.minHeight).toBe('var(--touch-row-min-h, 0px)');
});
diff --git a/lib/__tests__/return-to-list.test.ts b/lib/__tests__/return-to-list.test.ts
index a19b16d2b..0a3877791 100644
--- a/lib/__tests__/return-to-list.test.ts
+++ b/lib/__tests__/return-to-list.test.ts
@@ -9,7 +9,7 @@ const untouched = { returnToListAfterAction: true, explicitSettings: [] as strin
const chosen = (value: boolean) => ({ returnToListAfterAction: value, explicitSettings: ['returnToListAfterAction'] });
describe('listBesideReader', () => {
- const desktop = { isMobile: false, isTablet: false, interfaceLayout: 'default' as const };
+ const desktop = { isMobile: false, interfaceLayout: 'default' as const };
it('is true for the split and horizontal layouts on a desktop', () => {
expect(listBesideReader({ ...desktop, mailLayout: 'split' })).toBe(true);
expect(listBesideReader({ ...desktop, mailLayout: 'horizontal' })).toBe(true);
@@ -19,7 +19,6 @@ describe('listBesideReader', () => {
expect(listBesideReader({ ...desktop, mailLayout: 'split', interfaceLayout: 'mountain-view' })).toBe(false);
expect(listBesideReader({ ...desktop, mailLayout: 'split', simpleMode: true })).toBe(false);
expect(listBesideReader({ ...desktop, mailLayout: 'split', isMobile: true })).toBe(false);
- expect(listBesideReader({ ...desktop, mailLayout: 'split', isTablet: true })).toBe(false);
});
});
diff --git a/lib/boot-skeleton/__tests__/boot-script.test.ts b/lib/boot-skeleton/__tests__/boot-script.test.ts
index 9e0bcef60..c1376152e 100644
--- a/lib/boot-skeleton/__tests__/boot-script.test.ts
+++ b/lib/boot-skeleton/__tests__/boot-script.test.ts
@@ -22,7 +22,7 @@ const signedIn = () =>
function desktop(matches: boolean) {
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
- matches: matches && query.includes('64rem'),
+ matches: matches && query.includes('37.5rem'),
media: query,
addEventListener: () => {},
removeEventListener: () => {},
diff --git a/lib/boot-skeleton/boot-script.ts b/lib/boot-skeleton/boot-script.ts
index e6f49a2a1..ad1818296 100644
--- a/lib/boot-skeleton/boot-script.ts
+++ b/lib/boot-skeleton/boot-script.ts
@@ -176,7 +176,8 @@ export function applyBootLayout(root: HTMLElement, c: BootLayoutConfig): void {
mark('mail-layout', mailLayout);
var desktop = false;
- try { desktop = w.matchMedia('(min-width: 64rem)').matches; } catch { /* assume not */ }
+ // The desktop layout, compact included (PHONE_BELOW in hooks/use-media-query).
+ try { desktop = w.matchMedia('(min-width: 37.5rem)').matches; } catch { /* assume not */ }
var proOn = s.proInterface === true && desktop && (surface === 'pro' || has(c.pro, surface));
mark('pro', proOn ? 'on' : null);
diff --git a/lib/return-to-list.ts b/lib/return-to-list.ts
index f33055bcb..5d5426e30 100644
--- a/lib/return-to-list.ts
+++ b/lib/return-to-list.ts
@@ -5,17 +5,18 @@ const KEY = 'returnToListAfterAction';
/**
* Whether the message list and the reading pane are on screen side by side:
- * the split and horizontal layouts on a desktop. Phones, tablets, the focused
- * layout, Simple mode and Mountain View put the message in the list's place.
+ * the split and horizontal layouts on a desktop. Phones, the focused layout,
+ * Simple mode and Mountain View put the message in the list's place, and so
+ * does the split layout in a window too narrow for both (pass the layout as
+ * drawn, useDrawnMailLayout).
*/
export function listBesideReader(opts: {
mailLayout: MailLayout;
interfaceLayout: InterfaceLayout;
simpleMode?: boolean;
isMobile: boolean;
- isTablet: boolean;
}): boolean {
- if (opts.isMobile || opts.isTablet) return false;
+ if (opts.isMobile) return false;
return effectiveMailLayout(opts.mailLayout, opts.interfaceLayout, opts.simpleMode) !== 'focus';
}
diff --git a/stores/__tests__/ui-store.test.ts b/stores/__tests__/ui-store.test.ts
index 65d2d265a..d4f0c4a50 100644
--- a/stores/__tests__/ui-store.test.ts
+++ b/stores/__tests__/ui-store.test.ts
@@ -1,14 +1,14 @@
import { describe, it, expect, beforeEach } from 'vitest';
-import { useUIStore, listPaneCssSize, COMPOSER_PANE_MIN_HEIGHT } from '../ui-store';
+import { useUIStore, listPaneCssSize, sidebarFolded, pluginDeviceType, COMPOSER_PANE_MIN_HEIGHT } from '../ui-store';
+import { drawnMailLayout } from '@/hooks/use-effective-mail-layout';
describe('ui-store', () => {
beforeEach(() => {
useUIStore.setState({
activeView: 'list',
sidebarOpen: false,
- tabletListVisible: true,
isMobile: false,
- isTablet: false,
+ isCompact: false,
isDesktop: true,
sidebarWidth: 256,
emailListWidth: 384,
@@ -124,4 +124,49 @@ describe('ui-store', () => {
expect(useUIStore.getState().sidebarCollapsed).toBe(true);
});
});
+
+ // A compact desktop (600-1024px) folds the folders to icons until opened,
+ // and leaves the full desktop's own choice alone.
+ describe('the folder sidebar on a compact desktop', () => {
+ beforeEach(() => {
+ useUIStore.setState({ isMobile: false, isCompact: true, isDesktop: true, compactSidebarExpanded: false });
+ });
+
+ it('starts folded, whatever the full desktop chose', () => {
+ expect(sidebarFolded(useUIStore.getState())).toBe(true);
+ useUIStore.setState({ isCompact: false });
+ expect(sidebarFolded(useUIStore.getState())).toBe(false);
+ });
+
+ it('opens and folds again with the toggle, without changing the full desktop choice', () => {
+ useUIStore.getState().toggleSidebarCollapsed();
+ expect(sidebarFolded(useUIStore.getState())).toBe(false);
+ expect(useUIStore.getState().sidebarCollapsed).toBe(false);
+ useUIStore.getState().toggleSidebarCollapsed();
+ expect(sidebarFolded(useUIStore.getState())).toBe(true);
+ expect(useUIStore.getState().sidebarCollapsed).toBe(false);
+ });
+
+ it('follows a Pro pane\'s own class when given', () => {
+ useUIStore.setState({ isCompact: false, sidebarCollapsed: false });
+ expect(sidebarFolded(useUIStore.getState(), true)).toBe(true);
+ });
+ });
+
+ describe('pluginDeviceType', () => {
+ it('calls the compact desktop a tablet', () => {
+ expect(pluginDeviceType({ isMobile: true, isCompact: false })).toBe('mobile');
+ expect(pluginDeviceType({ isMobile: false, isCompact: true })).toBe('tablet');
+ expect(pluginDeviceType({ isMobile: false, isCompact: false })).toBe('desktop');
+ });
+ });
+
+ describe('drawnMailLayout', () => {
+ it('opens messages in the list\'s place when two panes do not fit', () => {
+ expect(drawnMailLayout('split', false)).toBe('focus');
+ expect(drawnMailLayout('split', true)).toBe('split');
+ expect(drawnMailLayout('horizontal', false)).toBe('horizontal');
+ expect(drawnMailLayout('focus', true)).toBe('focus');
+ });
+ });
});
diff --git a/stores/ui-store.ts b/stores/ui-store.ts
index 6d4799d52..ea6c3c740 100644
--- a/stores/ui-store.ts
+++ b/stores/ui-store.ts
@@ -38,12 +38,10 @@ interface UIState {
activeView: ActiveView;
sidebarOpen: boolean;
- // Tablet list visibility (auto-hide when email selected)
- tabletListVisible: boolean;
-
- // Device detection (hydrated client-side)
+ // Device detection (hydrated client-side), see classifyWidth in
+ // hooks/use-media-query: phone, compact desktop, desktop.
isMobile: boolean;
- isTablet: boolean;
+ isCompact: boolean;
isDesktop: boolean;
// Resizable column widths (desktop only)
@@ -53,6 +51,10 @@ interface UIState {
// Sidebar collapsed state (desktop)
sidebarCollapsed: boolean;
+ // A compact desktop shows the folder sidebar as icons until it is opened;
+ // this is whether it was, for this session. The full desktop's choice
+ // (sidebarCollapsed) stays as it was.
+ compactSidebarExpanded: boolean;
// Open composers and expanded quick replies. Floating cards such as the
// PWA install prompt stay hidden while any is open and toasts lift clear,
@@ -63,8 +65,7 @@ interface UIState {
setActiveView: (view: ActiveView) => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
- setTabletListVisible: (visible: boolean) => void;
- setDeviceType: (isMobile: boolean, isTablet: boolean, isDesktop: boolean) => void;
+ setDeviceType: (isMobile: boolean, isCompact: boolean, isDesktop: boolean) => void;
setSidebarWidth: (width: number) => void;
setEmailListWidth: (width: number) => void;
setEmailListHeight: (height: number) => void;
@@ -73,6 +74,7 @@ interface UIState {
resetEmailListHeight: () => void;
persistColumnWidths: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
+ /** Folds or unfolds the folder sidebar in the layout drawn now (compact or full). */
toggleSidebarCollapsed: () => void;
beginComposing: () => void;
endComposing: () => void;
@@ -89,14 +91,14 @@ export const useUIStore = create((set, get) => ({
// Initial state (SSR-safe defaults)
activeView: "list",
sidebarOpen: false,
- tabletListVisible: true,
isMobile: false,
- isTablet: false,
+ isCompact: false,
isDesktop: true,
sidebarWidth: SIDEBAR_DEFAULT,
emailListWidth: EMAIL_LIST_DEFAULT,
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
sidebarCollapsed: false,
+ compactSidebarExpanded: false,
composingCount: 0,
// Actions
@@ -106,10 +108,8 @@ export const useUIStore = create((set, get) => ({
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
- setTabletListVisible: (visible) => set({ tabletListVisible: visible }),
-
- setDeviceType: (isMobile, isTablet, isDesktop) =>
- set({ isMobile, isTablet, isDesktop }),
+ setDeviceType: (isMobile, isCompact, isDesktop) =>
+ set({ isMobile, isCompact, isDesktop }),
setSidebarWidth: (width) =>
set({ sidebarWidth: Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, width)) }),
@@ -156,7 +156,9 @@ export const useUIStore = create((set, get) => ({
},
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
- toggleSidebarCollapsed: () => set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
+ toggleSidebarCollapsed: () => set((state) => (state.isCompact
+ ? { compactSidebarExpanded: !state.compactSidebarExpanded }
+ : { sidebarCollapsed: !state.sidebarCollapsed })),
beginComposing: () => set((state) => ({ composingCount: state.composingCount + 1 })),
endComposing: () => set((state) => ({ composingCount: Math.max(0, state.composingCount - 1) })),
@@ -211,8 +213,22 @@ function reportColumnWidths(widths: ColumnWidths): void {
}
}
-function deviceType(s: Pick): "mobile" | "tablet" | "desktop" {
- return s.isMobile ? "mobile" : s.isTablet ? "tablet" : "desktop";
+/**
+ * The folder sidebar shows only its icons: on a compact desktop until opened,
+ * on the full desktop when the user folded it. `isCompact` is the pane's own
+ * class inside a Pro pane.
+ */
+export function sidebarFolded(s: Pick, isCompact = s.isCompact): boolean {
+ return isCompact ? !s.compactSidebarExpanded : s.sidebarCollapsed;
+}
+
+/**
+ * The device type plugins see. The compact desktop keeps the name "tablet":
+ * it covers the widths that were the tablet layout, and a plugin uses it to
+ * draw something narrower than on the full desktop.
+ */
+export function pluginDeviceType(s: Pick): "mobile" | "tablet" | "desktop" {
+ return s.isMobile ? "mobile" : s.isCompact ? "tablet" : "desktop";
}
// Mirrored on for CSS: toasts lift clear of Send (see toast.tsx).
@@ -231,5 +247,5 @@ useUIStore.subscribe((state, prev) => {
if (state.activeView !== prev.activeView) void uiHooks.onViewChange.emit({ view: state.activeView });
if (state.sidebarOpen !== prev.sidebarOpen) void uiHooks.onSidebarToggle.emit({ open: state.sidebarOpen });
if (state.sidebarCollapsed !== prev.sidebarCollapsed) void uiHooks.onSidebarCollapse.emit({ collapsed: state.sidebarCollapsed });
- if (deviceType(state) !== deviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: deviceType(state) });
+ if (pluginDeviceType(state) !== pluginDeviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: pluginDeviceType(state) });
});