From e0eecc52712a05a5d7dfe3eb2847a4df437f4990 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 13:59:34 -0400 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusActions.test.ts | 2 + src/lotus/lotusActions.ts | 13 ++- src/lotus/lotusControls.ts | 3 + src/lotus/lotusHotkeys.test.ts | 50 +++++++++ src/lotus/lotusHotkeys.ts | 159 ++++++++++++++++++++++++++++ src/room/InCallView.tsx | 2 + src/useCallViewKeyboardShortcuts.ts | 4 + 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/lotus/lotusHotkeys.test.ts create mode 100644 src/lotus/lotusHotkeys.ts diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts index 1431777f..ed994d3e 100644 --- a/src/lotus/lotusActions.test.ts +++ b/src/lotus/lotusActions.test.ts @@ -31,6 +31,7 @@ describe("LotusWidgetActions", () => { LotusWidgetActions.OpenSettings, LotusWidgetActions.ToggleReactions, LotusWidgetActions.SetScreenshare, + LotusWidgetActions.SetHotkeys, ]; expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget)); @@ -52,5 +53,6 @@ describe("LotusWidgetActions", () => { LotusWidgetActions.ControlsState, ); expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel); + expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.Hotkey); }); }); diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts index 2074448b..7d2feabb 100644 --- a/src/lotus/lotusActions.ts +++ b/src/lotus/lotusActions.ts @@ -81,9 +81,19 @@ export enum LotusWidgetActions { * without it the browser rejects the share. */ SetScreenshare = "io.lotus.set_screenshare", + /** + * toWidget: `{ codes: string[] }` — KeyboardEvent.code values (PTT, deafen) + * to report while focus is inside this frame (cinny #43). `[]` stops. + */ + SetHotkeys = "io.lotus.set_hotkeys", + /** + * fromWidget: a watched key went down/up here, or this window's focus + * changed; see `LotusHotkeyReport` (cinny #43). + */ + Hotkey = "io.lotus.hotkey", /** * fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null, - * screenshareAction: true }` + * screenshareAction: true, hotkeys: true }` * whenever either changes (cinny #43), so the host stops reading EC's DOM for * them. Its arrival also tells the host this fork supports the actions above. */ @@ -107,4 +117,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [ LotusWidgetActions.OpenSettings, LotusWidgetActions.ToggleReactions, LotusWidgetActions.SetScreenshare, + LotusWidgetActions.SetHotkeys, ]; diff --git a/src/lotus/lotusControls.ts b/src/lotus/lotusControls.ts index 17d544eb..803427ed 100644 --- a/src/lotus/lotusControls.ts +++ b/src/lotus/lotusControls.ts @@ -23,6 +23,8 @@ export interface LotusControlsState { layout: LayoutMode | null; /** This fork handles `io.lotus.set_screenshare` (always true). */ screenshareAction: true; + /** This fork handles `io.lotus.set_hotkeys` (always true). */ + hotkeys: true; } /** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */ @@ -112,6 +114,7 @@ export function startLotusControls(vm: CallViewModel): () => void { screensharing, layout, screenshareAction: true, + hotkeys: true, }), ), distinctUntilChanged( diff --git a/src/lotus/lotusHotkeys.test.ts b/src/lotus/lotusHotkeys.test.ts new file mode 100644 index 00000000..f9a386f5 --- /dev/null +++ b/src/lotus/lotusHotkeys.test.ts @@ -0,0 +1,50 @@ +/* +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 { describe, expect, it } from "vitest"; + +import { + isEditableTarget, + isInteractiveTarget, + parseHotkeyCodes, +} from "./lotusHotkeys"; + +describe("parseHotkeyCodes", () => { + it("keeps KeyboardEvent.code-like strings", () => { + expect(parseHotkeyCodes({ codes: ["Space", "KeyM", "F13"] })).toEqual([ + "Space", + "KeyM", + "F13", + ]); + }); + it("drops junk and caps the list", () => { + expect(parseHotkeyCodes({ codes: ["Space", 3, "", "a b", ""] })).toEqual( + ["Space"], + ); + expect(parseHotkeyCodes({ codes: Array(20).fill("KeyA") })).toHaveLength(8); + expect(parseHotkeyCodes({})).toEqual([]); + expect(parseHotkeyCodes(null)).toEqual([]); + }); +}); + +describe("target checks", () => { + it("treats inputs and contenteditable as editable", () => { + document.body.innerHTML = + '
x
'; + expect(isEditableTarget(document.getElementById("i"))).toBe(true); + expect(isEditableTarget(document.getElementById("s"))).toBe(true); + expect(isEditableTarget(document.getElementById("d"))).toBe(false); + expect(isEditableTarget(null)).toBe(false); + }); + it("treats buttons, links and button roles as interactive", () => { + document.body.innerHTML = + '
m

'; + expect(isInteractiveTarget(document.getElementById("b"))).toBe(true); + expect(isInteractiveTarget(document.getElementById("m"))).toBe(true); + expect(isInteractiveTarget(document.getElementById("p"))).toBe(false); + }); +}); diff --git a/src/lotus/lotusHotkeys.ts b/src/lotus/lotusHotkeys.ts new file mode 100644 index 00000000..52b6a611 --- /dev/null +++ b/src/lotus/lotusHotkeys.ts @@ -0,0 +1,159 @@ +/* +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(); + }; +} diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8b4199a0..e2c2791a 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -32,6 +32,7 @@ import { widget } from "../widget"; import { startLotusCallState } from "../lotus/lotusCallState"; import { startLotusFocus } from "../lotus/lotusFocus"; import { startLotusControls } from "../lotus/lotusControls"; +import { startLotusHotkeys } from "../lotus/lotusHotkeys"; import { startLotusMicLevel } from "../lotus/lotusMicLevel"; import { startLotusAudioInject } from "../lotus/lotusAudioInject"; import { startLotusQuality } from "../lotus/lotusQuality"; @@ -305,6 +306,7 @@ export const InCallView: FC = ({ // [cinny #43] layout / settings / reactions over the widget API, plus a // screensharing + layout report, replacing the host's DOM access. useEffect(() => startLotusControls(vm), [vm]); + useEffect(() => startLotusHotkeys(), []); // [cinny #146] Local mic level for the host's mute-button meter. useEffect(() => startLotusMicLevel(vm), [vm]); // [lotus] Handle the host's io.lotus.inject_audio action to mix a soundboard diff --git a/src/useCallViewKeyboardShortcuts.ts b/src/useCallViewKeyboardShortcuts.ts index 9022d22f..d6756b24 100644 --- a/src/useCallViewKeyboardShortcuts.ts +++ b/src/useCallViewKeyboardShortcuts.ts @@ -14,6 +14,7 @@ import { ReactionSet, ReactionsRowSize, } from "./reactions"; +import { isLotusHostHotkey } from "./lotus/lotusHotkeys"; /** * Determines whether focus is in the same part of the tree as the given @@ -86,6 +87,8 @@ export function useCallViewKeyboardShortcuts( useCallback( (event: KeyboardEvent) => { logger.info("Keydown event", event); + // [lotus] The Lotus host owns its PTT / deafen keys (cinny #43). + if (isLotusHostHotkey(event.code)) return; if (!mayReceiveKeyEvents()) return; if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; @@ -132,6 +135,7 @@ export function useCallViewKeyboardShortcuts( "keyup", useCallback( (event: KeyboardEvent) => { + if (isLotusHostHotkey(event.code)) return; if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return; if (event.key === " ") { spacebarHeld.current = false;