Files
webmail/stores/ui-store.ts
T
Linus Rath 044647ff34 fix: keep the desktop layout in a half-screen window and at 150 % zoom
A window snapped to half the screen (640-800 px) or a 1366x768 laptop
at 150 % (911 px) got the phone and tablet layout: bottom tab bar,
folders in a drawer, Settings as a phone list with a Back arrow. The
layout switched at the 64rem (1024 px) breakpoint.

Now the phone layout starts below 600 px. From 600 to 1024 px the app
is a compact desktop: app rail at the side, folder sidebar folded to
its icons (the existing collapsed state, opened per session with its
toggle), Settings with its two panes and a narrower page list. Below
840 px the split layout opens a message in the list's place, as the
focused layout does. This holds in the browser and the desktop app,
in the Pro shell (which now runs from 600 px) and under Mountain View.

- `hooks/use-media-query.ts`: classifyWidth, PHONE_BELOW 600,
  FULL_DESKTOP 1024; the tablet class (`isTablet`) is gone, the store
  has `isCompact` instead. Plugins still see "tablet" for it.
- `app/globals.css`: a `desk:` breakpoint at 37.5rem for the layout
  classes, and a `tap:` variant for 44px rows on phones and touch
  screens below 1024 px (was every window below 1024 px).
- Touch screens below 1024 px keep swiping list rows (was phones only).
- The boot skeletons, boot script, mail app, sidebar, reader, admin
  shell and calendar, contacts and files follow the new widths.

Tests changed with the behaviour: the tablet cases in pro-gate,
mail-app-mv-composer-move, mail-app-compose-handoff,
mail-app-thread-expand-mark-read, mobile-tour-end and
thread-list-item-columns now run on a phone or a compact desktop, as
those widths no longer exist as a tablet. mail-app-short-window-tab-bar
is replaced by mail-app-compact-layout: a phone on its side is a
compact desktop with the rail at the side, so the short-window tab bar
rule is gone. Class and media-query assertions follow desk:/tap: and
37.5rem; the 32px-text test uses 1100 px, since 1440 px at 32px is now
a compact desktop.
2026-11-04 09:07:06 +01:00

252 lines
9.6 KiB
TypeScript

"use client";
import { create } from "zustand";
import { uiHooks } from "@/lib/plugin-hooks";
export type ActiveView = "sidebar" | "list" | "viewer";
// Column width constraints (in pixels)
export const SIDEBAR_MIN = 180;
export const SIDEBAR_MAX = 400;
const SIDEBAR_DEFAULT = 256;
export const EMAIL_LIST_MIN = 240;
export const EMAIL_LIST_MAX = 600;
const EMAIL_LIST_DEFAULT = 384;
// Email list height (in pixels) for horizontal "Reading Pane at Bottom" layout
export const EMAIL_LIST_HEIGHT_MIN = 160;
export const EMAIL_LIST_HEIGHT_MAX = 800;
const EMAIL_LIST_HEIGHT_DEFAULT = 320;
// What the reading pane keeps of the window, whatever size the list was dragged to
export const READING_PANE_MIN_WIDTH = 320;
export const READING_PANE_MIN_HEIGHT = 240;
// A composer below the list needs room for its fields and some of the body. In
// rem because those fields grow with the text size.
export const COMPOSER_PANE_MIN_HEIGHT = '30rem';
/**
* The list pane's CSS size: the size the user dragged it to, as long as that
* leaves the reading pane its minimum. Saved sizes outlive the window they were
* set in, and a smaller window would squeeze the reading pane down to nothing.
*/
export function listPaneCssSize(saved: number, min: number, readingPaneMin: number | string): string {
const reserved = typeof readingPaneMin === 'number' ? `${readingPaneMin}px` : readingPaneMin;
return `clamp(${Math.min(saved, min)}px, calc(100% - ${reserved}), ${saved}px)`;
}
interface UIState {
// Mobile view state
activeView: ActiveView;
sidebarOpen: boolean;
// Device detection (hydrated client-side), see classifyWidth in
// hooks/use-media-query: phone, compact desktop, desktop.
isMobile: boolean;
isCompact: boolean;
isDesktop: boolean;
// Resizable column widths (desktop only)
sidebarWidth: number;
emailListWidth: number;
emailListHeight: number;
// 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,
// so neither sits on top of a Send button.
composingCount: number;
// Actions
setActiveView: (view: ActiveView) => void;
setSidebarOpen: (open: boolean) => void;
toggleSidebar: () => void;
setDeviceType: (isMobile: boolean, isCompact: boolean, isDesktop: boolean) => void;
setSidebarWidth: (width: number) => void;
setEmailListWidth: (width: number) => void;
setEmailListHeight: (height: number) => void;
resetSidebarWidth: () => void;
resetEmailListWidth: () => void;
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;
// Navigation helpers
showEmailList: () => void;
showEmailViewer: () => void;
goBack: () => void;
}
// Column widths are hydrated from localStorage by the page component on mount
export const useUIStore = create<UIState>((set, get) => ({
// Initial state (SSR-safe defaults)
activeView: "list",
sidebarOpen: false,
isMobile: false,
isCompact: false,
isDesktop: true,
sidebarWidth: SIDEBAR_DEFAULT,
emailListWidth: EMAIL_LIST_DEFAULT,
emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT,
sidebarCollapsed: false,
compactSidebarExpanded: false,
composingCount: 0,
// Actions
setActiveView: (view) => set({ activeView: view }),
setSidebarOpen: (open) => set({ sidebarOpen: open }),
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setDeviceType: (isMobile, isCompact, isDesktop) =>
set({ isMobile, isCompact, isDesktop }),
setSidebarWidth: (width) =>
set({ sidebarWidth: Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, width)) }),
setEmailListWidth: (width) =>
set({ emailListWidth: Math.min(EMAIL_LIST_MAX, Math.max(EMAIL_LIST_MIN, width)) }),
setEmailListHeight: (height) =>
set({ emailListHeight: Math.min(EMAIL_LIST_HEIGHT_MAX, Math.max(EMAIL_LIST_HEIGHT_MIN, height)) }),
resetSidebarWidth: () => {
set({ sidebarWidth: SIDEBAR_DEFAULT });
reportColumnWidths(get());
const { emailListWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth: SIDEBAR_DEFAULT, emailListWidth, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},
resetEmailListWidth: () => {
set({ emailListWidth: EMAIL_LIST_DEFAULT });
reportColumnWidths(get());
const { sidebarWidth, emailListHeight } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth: EMAIL_LIST_DEFAULT, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},
resetEmailListHeight: () => {
set({ emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT });
reportColumnWidths(get());
const { sidebarWidth, emailListWidth } = get();
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight: EMAIL_LIST_HEIGHT_DEFAULT }));
} catch { /* localStorage may be unavailable */ }
},
persistColumnWidths: () => {
const { sidebarWidth, emailListWidth, emailListHeight } = get();
reportColumnWidths({ sidebarWidth, emailListWidth, emailListHeight });
try {
localStorage.setItem("column-widths", JSON.stringify({ sidebarWidth, emailListWidth, emailListHeight }));
} catch { /* localStorage may be unavailable */ }
},
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
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) })),
// Navigation helpers for mobile
showEmailList: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "list", sidebarOpen: false });
}
},
showEmailViewer: () => {
const { isMobile } = get();
if (isMobile) {
set({ activeView: "viewer" });
}
},
goBack: () => {
const { activeView, isMobile } = get();
if (!isMobile) return;
void uiHooks.onMobileBack.emit({});
if (activeView === "viewer") {
set({ activeView: "list" });
} else if (activeView === "list") {
set({ sidebarOpen: true });
}
},
}));
type ColumnWidths = Pick<UIState, "sidebarWidth" | "emailListWidth" | "emailListHeight">;
const COLUMN_NAMES: Record<keyof ColumnWidths, string> = {
sidebarWidth: "sidebar",
emailListWidth: "email-list",
emailListHeight: "email-list-height",
};
let reportedWidths: ColumnWidths | null = null;
// Called when a resize ends (widths are saved then), not on every pixel of
// the drag. Reports only the columns that changed since the last report.
function reportColumnWidths(widths: ColumnWidths): void {
const before = reportedWidths;
reportedWidths = { sidebarWidth: widths.sidebarWidth, emailListWidth: widths.emailListWidth, emailListHeight: widths.emailListHeight };
if (!before) return;
for (const key of Object.keys(COLUMN_NAMES) as (keyof ColumnWidths)[]) {
if (widths[key] !== before[key]) {
void uiHooks.onColumnResize.emit({ column: COLUMN_NAMES[key], width: widths[key] });
}
}
}
/**
* 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<UIState, "isCompact" | "sidebarCollapsed" | "compactSidebarExpanded">, 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<UIState, "isMobile" | "isCompact">): "mobile" | "tablet" | "desktop" {
return s.isMobile ? "mobile" : s.isCompact ? "tablet" : "desktop";
}
// Mirrored on <html> for CSS: toasts lift clear of Send (see toast.tsx).
useUIStore.subscribe((state, prev) => {
if (state.composingCount === prev.composingCount || typeof document === "undefined") return;
const root = document.documentElement;
if (state.composingCount > 0) root.dataset.composerOpen = String(state.composingCount);
else delete root.dataset.composerOpen;
});
// Plugins hear about layout changes, whichever control or effect made them.
useUIStore.subscribe((state, prev) => {
if (!reportedWidths) {
reportedWidths = { sidebarWidth: prev.sidebarWidth, emailListWidth: prev.emailListWidth, emailListHeight: prev.emailListHeight };
}
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 (pluginDeviceType(state) !== pluginDeviceType(prev)) void uiHooks.onDeviceTypeChange.emit({ deviceType: pluginDeviceType(state) });
});