Files
webmail/lib/window-controls-overlay.ts
T
Linus Rath bb2393cf04 feat: open messages and drafts in their own windows and save all attachments to a folder
An installed app had no title bar of its own, no way to save attachments
except a zip, and "pop out" stayed inside the page. A new window was a
second copy of the app that had to restore the session itself, and on
start-up that evicts any login made without "Remember me" from the list
every window shares.

- Title bar: `display_override` with `window-controls-overlay`, one
  `--titlebar-inset` variable at the page roots and skeletons, and a drag
  strip with the window title. 0 without an overlay, so nothing moves.
- Save all to a folder: `showDirectoryPicker`, asking the device policy
  before the picker and before each file, with the zip as the fallback.
- Pop-out routes `/popout/message/<id>` and `/popout/compose`, on offer in
  an installed app or when the desktop bridge has windows. Hidden in Lite.
- A pop-out borrows the main window's session and never evicts or starts a
  token timer. Every renewal now takes a Web Lock, so two windows can't
  send the same refresh token, which Stalwart 1.0 may treat as a replay.
- A draft moves to its window under the same id. A closing window hands
  unsaved text to the main window to save, since its own request wouldn't
  survive the close.
- Read state, drafts, theme and language follow across windows, and
  `html[data-desktop]` gates the app-like behaviour in `app/desktop.css`.
2026-11-03 06:50:21 +01:00

69 lines
2.7 KiB
TypeScript

/**
* Window Controls Overlay: an installed app (and the desktop app's
* titleBarOverlay window) draws the page up to the top of the window and lays
* the native minimise, maximise and close buttons over it.
*
* Asks navigator.windowControlsOverlay, never the media query:
* `(display-mode: window-controls-overlay)` is false in Electron with
* titleBarOverlay, while the API and env(titlebar-area-*) work there.
*
* app/globals.css gives --titlebar-inset its first value from
* env(titlebar-area-height, 0px), so the page has the right top padding from
* the first paint, before any script runs. This module keeps the variable in
* step with the API afterwards, which is what a browser or bridge without the
* env() values relies on.
*/
export interface TitlebarArea {
x: number;
y: number;
width: number;
height: number;
}
interface OverlayLike extends EventTarget {
visible: boolean;
getTitlebarAreaRect?: () => { x: number; y: number; width: number; height: number };
}
export const TITLEBAR_INSET_VAR = '--titlebar-inset';
function overlay(): OverlayLike | null {
if (typeof navigator === 'undefined') return null;
const value = (navigator as Navigator & { windowControlsOverlay?: OverlayLike }).windowControlsOverlay;
return value ?? null;
}
/** The strip the page may draw in next to the window's controls, or null when there is no overlay. */
export function readTitlebarArea(): TitlebarArea | null {
const current = overlay();
if (!current?.visible) return null;
const rect = current.getTitlebarAreaRect?.();
if (!rect || !(rect.height > 0)) return null;
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
/**
* Sets --titlebar-inset for this area. With no area the inline value goes, so
* the stylesheet's value (0 without an overlay) is what counts again.
*/
export function applyTitlebarInset(area: TitlebarArea | null, root: HTMLElement = document.documentElement): void {
if (area) root.style.setProperty(TITLEBAR_INSET_VAR, `${area.height}px`);
else root.style.removeProperty(TITLEBAR_INSET_VAR);
}
/**
* Calls `listener` now and whenever the overlay's size or visibility changes
* (`geometrychange`). Without the API it calls it once with null and listens
* to nothing, so a browser without it runs no extra code. Returns the
* unsubscribe.
*/
export function watchTitlebarArea(listener: (area: TitlebarArea | null) => void): () => void {
listener(readTitlebarArea());
const current = overlay();
if (!current) return () => {};
const onChange = () => listener(readTitlebarArea());
current.addEventListener('geometrychange', onChange);
return () => current.removeEventListener('geometrychange', onChange);
}