mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Review of the pop-out work found ways to lose text, a wrong sign-out and changes reaching ordinary browser tabs. - Drafts: a closing window hands text over only to a main window holding its login, and asks instead when the last save failed, the browser is offline, a save is running or plugins could still have a say. A failed rescue goes to "Continue draft". The opening window lets go only once the composer is on screen, also for Edit draft in a message window. Send options travel with the draft. Closing during a send asks first. - Sessions: answers are encrypted for the window that asked, and only a window holding the login answers. A renewal queued on the lock can be called off by a sign-out, signs nobody out once its account is gone, and takes the cached token after waiting (Rauthy `nbf`, #552). - Ordinary tabs run none of the host. No lock is held for life. - A message window hides after a refused session and waits for its folders; a composer stays on screen with its text. Another tab dropping a login it couldn't restore no longer closes pop-outs. - Plain-text mail stays selectable in the desktop app. Folder save renames reserved and shell names and skips one refused name. All display-mode checks know the overlay. The attachment bar keeps to one row. The earlier test that accepted text being gone after a failed rescue now asserts that it reaches the "Continue draft" slot.
101 lines
3.8 KiB
TypeScript
101 lines
3.8 KiB
TypeScript
/**
|
|
* Opens pop-out windows: one message, or a composer. Shared with the desktop
|
|
* app's quick compose, which only has to call openComposerWindow.
|
|
*/
|
|
|
|
import { appPath } from '@/lib/deep-links';
|
|
import '@/lib/desktop/types';
|
|
import { createComposeHandoff, type ComposeHandoff } from './compose-handoff';
|
|
import type { ComposePopoutPayload } from './compose-payload';
|
|
|
|
const SIZES = {
|
|
message: { width: 960, height: 820 },
|
|
compose: { width: 760, height: 780 },
|
|
} as const;
|
|
|
|
type PopoutKind = keyof typeof SIZES;
|
|
|
|
/** Offset from the opening window, so the new one doesn't hide it exactly. */
|
|
const CASCADE_PX = 48;
|
|
|
|
export function popoutFeatures(kind: PopoutKind): string {
|
|
const { width, height } = SIZES[kind];
|
|
const left = Math.round((window.screenX || 0) + CASCADE_PX);
|
|
const top = Math.round((window.screenY || 0) + CASCADE_PX);
|
|
return `popup=yes,width=${width},height=${height},left=${left},top=${top}`;
|
|
}
|
|
|
|
/** Windows this page opened. window.open with a name would load the address in it again. */
|
|
const openedWindows = new Map<string, Window>();
|
|
|
|
/** Whether a window opened. False when a pop-up blocker said no: the caller stays as it is. */
|
|
function openWindow(url: string, name: string, kind: PopoutKind): Window | boolean {
|
|
const bridge = window.bulwarkDesktop?.windows;
|
|
if (bridge?.open) {
|
|
bridge.open(url, { name, ...SIZES[kind] });
|
|
return true;
|
|
}
|
|
const existing = openedWindows.get(name);
|
|
if (existing && !existing.closed) return existing;
|
|
// Not noopener: in an installed app that is what sends a window to the
|
|
// system browser (lib/standalone-display.ts).
|
|
const opened = window.open(url, name, popoutFeatures(kind));
|
|
if (!opened) return false;
|
|
openedWindows.set(name, opened);
|
|
return opened;
|
|
}
|
|
|
|
function focused(opened: Window | boolean): boolean {
|
|
if (opened && typeof opened !== 'boolean') opened.focus?.();
|
|
return !!opened;
|
|
}
|
|
|
|
function query(params: Record<string, string | null | undefined>): string {
|
|
const search = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(params)) if (value) search.set(key, value);
|
|
const text = search.toString();
|
|
return text ? `?${text}` : '';
|
|
}
|
|
|
|
export interface MessageWindowTarget {
|
|
emailId: string;
|
|
/** The login the message is read through. */
|
|
localAccountId?: string | null;
|
|
/** The JMAP account holding it, for a shared or group folder. */
|
|
ownerAccountId?: string | null;
|
|
}
|
|
|
|
export function messagePopoutUrl(target: MessageWindowTarget): string {
|
|
return appPath(`/popout/message/${encodeURIComponent(target.emailId)}`)
|
|
+ query({ account: target.localAccountId, owner: target.ownerAccountId });
|
|
}
|
|
|
|
/** The same message twice focuses the same window, without loading it again. */
|
|
export function openMessageWindow(target: MessageWindowTarget): boolean {
|
|
return focused(openWindow(messagePopoutUrl(target), `bulwark-message-${target.emailId.replace(/\W/g, '_')}`, 'message'));
|
|
}
|
|
|
|
export interface ComposerWindow {
|
|
/** Hands the draft over. True once the composer is on screen there; until then the sender keeps its copy. */
|
|
handOver(payload: ComposePopoutPayload): Promise<boolean>;
|
|
/** Calls it off: the pop-out closes. */
|
|
cancel(): void;
|
|
}
|
|
|
|
/**
|
|
* Opens an empty pop-out composer that waits for its draft. Call it straight
|
|
* from the click: a browser only opens a window for a user action. Null when
|
|
* no window opened.
|
|
*/
|
|
export function openComposerWindow(localAccountId?: string | null): ComposerWindow | null {
|
|
const handoff: ComposeHandoff | null = createComposeHandoff();
|
|
if (!handoff) return null;
|
|
const url = appPath('/popout/compose') + query({ handoff: handoff.id, account: localAccountId });
|
|
const opened = openWindow(url, `bulwark-compose-${handoff.id}`, 'compose');
|
|
if (!focused(opened)) {
|
|
handoff.cancel();
|
|
return null;
|
|
}
|
|
return { handOver: handoff.complete, cancel: handoff.cancel };
|
|
}
|