Files
element-call/src/lotus/lotusHotkeys.ts
T

160 lines
5.3 KiB
TypeScript
Raw Normal View History

/*
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<string>();
/**
* 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<string>();
const onSetHotkeys = (ev: CustomEvent<IWidgetApiRequest>): 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<string>();
};
}