mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
The Move lists (message menu, reader Move button and its phone panel, selection toolbar, Move dialog) now share one model in lib/move-menu.ts. In the desktop app the native copy of the menu lost the shared accounts' headers and every folder's nesting, so Inbox, Sent, Spam and Trash showed once per account with nothing to tell them apart. - Only the message's own account at the top; other accounts a move can reach are submenus under their names. - System folders first (Inbox, Sent, Archive, Spam, Trash), then the user's folders by name; subfolders nest, as submenus natively. - The open folder shows greyed instead of missing. - A Recent group, a filter field on long lists, and "Choose folder…" for the dialog, which has the filter too. Tests changed: the open folder is now shown disabled rather than left out, the own account of a shared-mailbox message is a submenu, and the selection toolbar's filter field has its own label.
202 lines
7.7 KiB
TypeScript
202 lines
7.7 KiB
TypeScript
/**
|
|
* Turns an HTML menu into a native one. The ContextMenu component renders as it does
|
|
* in a browser, hidden, with its submenus open. This reads the result and shows that
|
|
* natively. A pick clicks the row the HTML menu has for it, so a menu has one list of
|
|
* entries with one set of handlers and the two can't drift.
|
|
*
|
|
* A menu with something a native one can't show (a colour picker, a row with no text)
|
|
* stays HTML. So does anything marked data-native-menu="html". A plugin's frame in the
|
|
* menu doesn't: the rest shows natively, with one line that opens the HTML menu.
|
|
*/
|
|
|
|
import { getDesktopBridge } from './bridge';
|
|
import type { DesktopNativeMenuItem } from './types';
|
|
|
|
export function nativeMenusAvailable(): boolean {
|
|
return !!getDesktopBridge()?.menu?.popup;
|
|
}
|
|
|
|
export interface ReadMenu {
|
|
items: DesktopNativeMenuItem[];
|
|
/** The HTML row behind each native item's id. */
|
|
elements: Map<string, HTMLElement>;
|
|
/** There's something here a native menu can't show. */
|
|
unsupported: boolean;
|
|
/** A plugin drew something of its own in the menu. */
|
|
hasFrames: boolean;
|
|
}
|
|
|
|
const ROW = '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], button, a[href]';
|
|
|
|
/** The native line that opens the HTML menu instead. */
|
|
export const HTML_MENU_ITEM = "html-menu";
|
|
|
|
function isDisabled(el: HTMLElement): boolean {
|
|
return (el as HTMLButtonElement).disabled === true || el.getAttribute('aria-disabled') === 'true';
|
|
}
|
|
|
|
function labelOf(el: HTMLElement): string {
|
|
const named = el.getAttribute('aria-label') ?? el.querySelector<HTMLElement>('[data-menu-label]')?.textContent ?? el.textContent ?? '';
|
|
return named.replace(/\s+/g, ' ').trim();
|
|
}
|
|
|
|
function isHidden(el: Element): boolean {
|
|
return typeof window.getComputedStyle === 'function' && window.getComputedStyle(el).display === 'none';
|
|
}
|
|
|
|
export function readMenu(root: HTMLElement): ReadMenu {
|
|
const elements = new Map<string, HTMLElement>();
|
|
let unsupported = root.querySelector('[data-native-menu="html"], select, textarea, input[type="color"]') !== null;
|
|
let hasFrames = false;
|
|
let next = 1;
|
|
|
|
const walk = (container: Element, into: DesktopNativeMenuItem[]): void => {
|
|
for (const child of Array.from(container.children)) {
|
|
if (!(child instanceof HTMLElement) || isHidden(child)) continue;
|
|
if (child.getAttribute('role') === 'separator') {
|
|
into.push({ type: 'separator' });
|
|
continue;
|
|
}
|
|
if (child.tagName === 'IFRAME') {
|
|
// Only a plugin's own slot has this coming. Anything else stays HTML whole.
|
|
if (child.closest('[data-plugin-slot]')) hasFrames = true;
|
|
else unsupported = true;
|
|
continue;
|
|
}
|
|
if (child.dataset.submenuPanel !== undefined) continue;
|
|
if (child.dataset.nativeSubmenu !== undefined) {
|
|
// A folder with subfolders (a Move list): a native menu can't indent,
|
|
// so it's a submenu with the folder itself first, as Thunderbird has it.
|
|
const group: DesktopNativeMenuItem[] = [];
|
|
walk(child, group);
|
|
const [head, ...rest] = group;
|
|
if (head && rest.length && head.type !== 'separator' && head.label) {
|
|
into.push({ label: head.label, submenu: [head, { type: 'separator' }, ...rest] });
|
|
} else {
|
|
into.push(...group);
|
|
}
|
|
continue;
|
|
}
|
|
if (child.dataset.contextMenuHeader !== undefined) {
|
|
const label = labelOf(child);
|
|
if (label) into.push({ id: `h${next++}`, label, enabled: false });
|
|
continue;
|
|
}
|
|
if (child.dataset.submenuTrigger !== undefined) {
|
|
const label = labelOf(child);
|
|
if (isDisabled(child)) {
|
|
into.push({ id: `s${next++}`, label, enabled: false });
|
|
continue;
|
|
}
|
|
const panel = child.parentElement?.querySelector<HTMLElement>(':scope > [data-submenu-panel]');
|
|
if (!panel) {
|
|
unsupported = true;
|
|
continue;
|
|
}
|
|
const submenu: DesktopNativeMenuItem[] = [];
|
|
walk(panel, submenu);
|
|
into.push({ label, submenu });
|
|
continue;
|
|
}
|
|
if (child.matches(ROW)) {
|
|
// A row that opens its own popup: there's nothing to click through to.
|
|
if (child.hasAttribute('aria-haspopup')) {
|
|
unsupported = true;
|
|
continue;
|
|
}
|
|
const label = labelOf(child);
|
|
if (!label) {
|
|
// A swatch or an icon says nothing in a native menu.
|
|
unsupported = true;
|
|
continue;
|
|
}
|
|
const id = `i${next++}`;
|
|
elements.set(id, child);
|
|
const role = child.getAttribute('role');
|
|
into.push({
|
|
id,
|
|
label,
|
|
enabled: !isDisabled(child),
|
|
...(role === 'menuitemcheckbox' || role === 'menuitemradio'
|
|
? { type: role === 'menuitemcheckbox' ? 'checkbox' as const : 'radio' as const, checked: child.getAttribute('aria-checked') === 'true' }
|
|
: {}),
|
|
});
|
|
continue;
|
|
}
|
|
// A search field over a list is no use where you can't type.
|
|
if (child instanceof HTMLInputElement) continue;
|
|
walk(child, into);
|
|
}
|
|
};
|
|
|
|
const items: DesktopNativeMenuItem[] = [];
|
|
walk(root, items);
|
|
return { items, elements, unsupported, hasFrames };
|
|
}
|
|
|
|
const SETTLE_MS = 30;
|
|
const SETTLE_LIMIT_MS = 400;
|
|
|
|
/**
|
|
* The menu once it stopped changing. Entries can render a moment after the menu
|
|
* (the mail menu waits for its data), and a native menu can't grow once it's up.
|
|
*/
|
|
export async function readSettledMenu(root: HTMLElement, cancelled: () => boolean): Promise<ReadMenu> {
|
|
const started = Date.now();
|
|
let previous = readMenu(root);
|
|
while (!cancelled() && Date.now() - started < SETTLE_LIMIT_MS) {
|
|
await new Promise((resolve) => setTimeout(resolve, SETTLE_MS));
|
|
const current = readMenu(root);
|
|
if (current.items.length > 0 && JSON.stringify(current.items) === JSON.stringify(previous.items)) return current;
|
|
previous = current;
|
|
}
|
|
return previous;
|
|
}
|
|
|
|
function labelOfId(items: DesktopNativeMenuItem[], id: string): string | undefined {
|
|
for (const item of items) {
|
|
if (item.id === id) return item.label;
|
|
const below = item.submenu && labelOfId(item.submenu, id);
|
|
if (below) return below;
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type NativeMenuOutcome = 'done' | 'html' | 'gone';
|
|
|
|
/**
|
|
* Shows it and clicks the row picked. Says when the pick found nothing: the page can
|
|
* re-render under a menu that stays up (the list refreshing), and a row that's gone
|
|
* would otherwise do nothing with no word about it. If the same line is back at the
|
|
* same place, that's the one.
|
|
*/
|
|
export async function showNativeMenu(read: ReadMenu, root: HTMLElement, at?: { x: number; y: number }, fromPlugins?: string): Promise<NativeMenuOutcome> {
|
|
const popup = getDesktopBridge()?.menu?.popup;
|
|
if (!popup) return 'done';
|
|
const items = read.hasFrames && fromPlugins
|
|
? [...read.items, { type: 'separator' as const }, { id: HTML_MENU_ITEM, label: fromPlugins }]
|
|
: read.items;
|
|
document.documentElement.dataset.nativeMenuOpen = '';
|
|
let picked: string | null;
|
|
try {
|
|
picked = await popup(items, at);
|
|
} finally {
|
|
delete document.documentElement.dataset.nativeMenuOpen;
|
|
}
|
|
if (!picked) return 'done';
|
|
if (picked === HTML_MENU_ITEM) return 'html';
|
|
let row = read.elements.get(picked);
|
|
if (!row?.isConnected) {
|
|
const again = root.isConnected ? readMenu(root) : null;
|
|
row = again && labelOfId(again.items, picked) === labelOfId(read.items, picked) ? again.elements.get(picked) : undefined;
|
|
}
|
|
if (!row?.isConnected) return 'gone';
|
|
row.click();
|
|
return 'done';
|
|
}
|
|
|
|
/** A native menu is up: the window's blur or a stray event says nothing about it. */
|
|
export function nativeMenuIsOpen(): boolean {
|
|
return typeof document !== 'undefined' && document.documentElement.dataset.nativeMenuOpen !== undefined;
|
|
}
|