/* Copyright 2026 Lotus Guild SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { type IWidgetApiRequest } from "matrix-widget-api"; import { widget } from "../widget"; import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget"; /** fromWidget `io.lotus.hotkey` payload. */ export type LotusHotkeyReport = | { type: "keydown" | "keyup"; code: string; repeat: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; shiftKey: boolean; /** The key went to a text field (typing, not a hotkey). */ editable: boolean; /** The key went to a button/link/etc. (Space activates it). */ interactive: boolean; } /** This window gained or lost focus: the host releases a held PTT. */ | { type: "focus" }; const MAX_CODES = 8; // Codes the host currently owns. Module-level so EC's own shortcuts // (useCallViewKeyboardShortcuts) can stand down for them. let hostCodes = new Set(); /** * Whether `code` is one of the host's call hotkeys (its PTT or deafen key). * EC's built-in shortcuts ignore such keys: with the defaults, M would * otherwise both toggle EC's mic and the host's deafen, and Space would open * EC's own push-to-talk alongside the host's. */ export function isLotusHostHotkey(code: string): boolean { return hostCodes.has(code); } /** `{ codes }` payload → the KeyboardEvent.code values to watch. Exported for tests. */ export function parseHotkeyCodes(data: unknown): string[] { if (typeof data !== "object" || data === null) return []; const { codes } = data as { codes?: unknown }; if (!Array.isArray(codes)) return []; return codes .filter( (c): c is string => typeof c === "string" && /^[A-Za-z0-9]{1,32}$/.test(c), ) .slice(0, MAX_CODES); } /** Same rules as the host's useCallHotkeys. Exported for tests. */ export function isEditableTarget(el: Element | null): boolean { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; let node: HTMLElement | null = el; while (node && node !== el.ownerDocument.body) { const ce = node.getAttribute("contenteditable"); if (ce === "" || ce === "true" || ce === "plaintext-only") return true; if (ce === "false") return false; node = node.parentElement; } return false; } /** Exported for tests. */ export function isInteractiveTarget(el: Element | null): boolean { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; if (tag === "BUTTON" || tag === "A" || tag === "SELECT") return true; let node: HTMLElement | null = el; while (node && node !== el.ownerDocument.body) { const role = node.getAttribute("role"); if ( role === "button" || role === "link" || role === "menuitem" || role === "tab" ) return true; node = node.parentElement; } return false; } /** * [cinny #43] Call hotkeys (push-to-talk, deafen) while focus is inside this * frame. The host used to add key listeners to this window directly, which * needs same-origin access. Now it sends `io.lotus.set_hotkeys { codes }` and * we report those keys back as `io.lotus.hotkey`. The default action is * cancelled here, synchronously (it can't be across frames), with the host's * rules: never while typing in a field, and not on a focused button/link so * keyboard users can still activate it. Returns a teardown function. */ export function startLotusHotkeys(): () => void { const w = widget; if (!w) return (): void => undefined; hostCodes = new Set(); const onSetHotkeys = (ev: CustomEvent): void => { w.api.transport.reply(ev.detail, {}); hostCodes = new Set(parseHotkeyCodes(ev.detail.data)); }; const onKey = (e: KeyboardEvent): void => { if (!hostCodes.has(e.code)) return; const target = e.target instanceof Element ? e.target : null; const editable = isEditableTarget(target); const interactive = isInteractiveTarget(target); if ( e.type === "keydown" && !editable && !interactive && !e.ctrlKey && !e.altKey && !e.metaKey ) { e.preventDefault(); } const report: LotusHotkeyReport = { type: e.type === "keyup" ? "keyup" : "keydown", code: e.code, repeat: e.repeat, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, shiftKey: e.shiftKey, editable, interactive, }; lotusSendToHost(LotusWidgetActions.Hotkey, report); }; const onFocusChange = (): void => { if (hostCodes.size > 0) lotusSendToHost(LotusWidgetActions.Hotkey, { type: "focus" }); }; w.lazyActions.on(LotusWidgetActions.SetHotkeys, onSetHotkeys); window.addEventListener("keydown", onKey, true); window.addEventListener("keyup", onKey, true); window.addEventListener("blur", onFocusChange); window.addEventListener("focus", onFocusChange); return (): void => { w.lazyActions.off(LotusWidgetActions.SetHotkeys, onSetHotkeys); window.removeEventListener("keydown", onKey, true); window.removeEventListener("keyup", onKey, true); window.removeEventListener("blur", onFocusChange); window.removeEventListener("focus", onFocusChange); hostCodes = new Set(); }; }