mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 16:16:49 +00:00
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.
252 lines
9.6 KiB
TypeScript
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) });
|
|
});
|