Files
element-call/src/lotus/lotusHotkeys.ts
T
Lotus CIandClaude Opus 5.5 e0eecc5271 feat(lotus): report host call hotkeys from inside the frame (cinny #43)
The host used to add PTT/deafen key listeners to this frame's window,
which needs same-origin access. Now it sends `io.lotus.set_hotkeys
{ codes }` and the fork reports those keys as `io.lotus.hotkey`
(keydown/keyup with modifiers + whether the target is a text field or a
button), plus window focus changes so a held PTT is released. The
default action is cancelled here with the host's rules (not while typing,
not on a focused button). `controls_state` carries `hotkeys: true`.

Fixes a live bug: EC's own shortcuts (M = toggle mic, Space = PTT) also
fired for the host's keys, so with focus in the call and the default
deafen key M, the first press turned the MIC ON instead of deafening
(even in push-to-talk mode). EC's shortcuts now ignore keys the host owns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-26 13:59:34 -04:00

160 lines
5.3 KiB
TypeScript

/*
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>();
};
}