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;