mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
93 lines
3.4 KiB
TypeScript
93 lines
3.4 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { historySettled } from "@/hooks/use-close-on-back";
|
|
|
|
/**
|
|
* Leave guard (R5): an editor with unsaved changes registers here, and the
|
|
* shell's own navigation (the desktop rail, the phone tab bar) asks it
|
|
* before leaving. The editor's in-app exits (Cancel, another item, Back)
|
|
* ask through the editor's own code; this covers what the editor can't see.
|
|
*/
|
|
export interface LeaveGuard {
|
|
/** True while there is something to lose. Read on every navigation. */
|
|
isDirty: () => boolean;
|
|
/**
|
|
* Ask the user; resolves true when the changes may be dropped. Only ask:
|
|
* the navigation that follows unmounts the editor, and clearing its state
|
|
* first can run its own clean-up (a Back guard's history step) before the
|
|
* navigation lands. The guard isn't asked again for that navigation.
|
|
*/
|
|
confirm: () => Promise<boolean>;
|
|
}
|
|
|
|
const guards = new Set<LeaveGuard>();
|
|
// Set while the guarded click is replayed, so it isn't stopped again.
|
|
let replaying = false;
|
|
let asking = false;
|
|
|
|
export function registerLeaveGuard(guard: LeaveGuard): () => void {
|
|
guards.add(guard);
|
|
return () => { guards.delete(guard); };
|
|
}
|
|
|
|
export function hasUnsavedChanges(): boolean {
|
|
for (const guard of guards) if (guard.isDirty()) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Asks each editor with changes in turn; true when all of them may be left. */
|
|
export async function confirmLeave(): Promise<boolean> {
|
|
for (const guard of [...guards]) {
|
|
if (guard.isDirty() && !(await guard.confirm())) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* For the click handler of a link or button that leaves the current app:
|
|
* when an editor has unsaved changes, stops the click, asks, and clicks the
|
|
* same element again once the user lets the changes go. Returns true when
|
|
* it stopped the click; the handler should then do nothing else.
|
|
*
|
|
* `replay` leaves in place of the second click, for an element that is gone
|
|
* by then (an item of a menu that closes).
|
|
*/
|
|
export function interceptLeave(
|
|
e: { preventDefault(): void; currentTarget: EventTarget | null },
|
|
replay?: () => void,
|
|
): boolean {
|
|
if (replaying || !hasUnsavedChanges()) return false;
|
|
e.preventDefault();
|
|
const target = e.currentTarget instanceof HTMLElement ? e.currentTarget : null;
|
|
if (asking) return true;
|
|
asking = true;
|
|
void confirmLeave().then(async (leave) => {
|
|
asking = false;
|
|
if (!leave || (!replay && !target)) return;
|
|
// The dialog that asked steps Back off its own history entry as it
|
|
// closes; a navigation started before that lands is undone by it.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
await historySettled();
|
|
replaying = true;
|
|
try {
|
|
if (replay) replay();
|
|
else target?.click();
|
|
} finally { replaying = false; }
|
|
});
|
|
return true;
|
|
}
|
|
|
|
/** For a way out that isn't a click (a menu command): leaves now, or once the user lets the changes go. */
|
|
export function leaveWhenAllowed(leave: () => void): void {
|
|
if (!interceptLeave({ preventDefault() {}, currentTarget: null }, leave)) leave();
|
|
}
|
|
|
|
/** Registers the calling editor's guard while it is mounted. */
|
|
export function useLeaveGuard(isDirty: () => boolean, confirm: () => Promise<boolean>): void {
|
|
const latest = useRef({ isDirty, confirm });
|
|
useEffect(() => { latest.current = { isDirty, confirm }; });
|
|
useEffect(() => registerLeaveGuard({
|
|
isDirty: () => latest.current.isDirty(),
|
|
confirm: () => latest.current.confirm(),
|
|
}), []);
|
|
}
|