Files
webmail/lib/desktop/window-sync.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

43 lines
1.7 KiB
TypeScript

/**
* Keeps what the windows of the app share up to date without a reload: theme,
* language and settings. Each is a persisted store, so a change in one window
* reaches the others as a `storage` event. Read state and drafts go through
* lib/popout/channel.ts, and each window keeps its own account (see
* local-data/desktop-offline-decisions.md).
*
* Only in pop-outs and the desktop app: tabs of a browser keep today's
* behaviour, where a change shows at the next load.
*/
import { useLocaleStore } from '@/stores/locale-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useThemeStore } from '@/stores/theme-store';
import { hasDesktopWindows } from '@/lib/popout/support';
import { isPopoutWindow } from '@/lib/popout/window-role';
export function windowSyncWanted(): boolean {
return isPopoutWindow() || hasDesktopWindows();
}
/** Starts listening; returns the stop. Does nothing where windowSyncWanted says no. */
export function startWindowSync(): () => void {
if (!windowSyncWanted()) return () => {};
const onStorage = (event: StorageEvent) => {
switch (event.key) {
case 'theme-storage':
// Rehydrating sets the state; this puts it on the page.
void Promise.resolve(useThemeStore.persist.rehydrate()).then(() => useThemeStore.getState().initializeTheme());
break;
case 'locale-storage':
// IntlProvider switches the catalog when the store changes.
void useLocaleStore.persist.rehydrate();
break;
case 'settings-storage':
void useSettingsStore.persist.rehydrate();
break;
}
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}