Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 dfb72bd9a5 chore(lotus): 0.25.0-lotus.19
CI / Publish to Gitea npm registry (push) Successful in 1m16s
CI / Build embedded bundle (push) Successful in 3m27s
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
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
8 changed files with 233 additions and 2 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.18",
"version": "0.25.0-lotus.19",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+2
View File
@@ -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);
});
});
+12 -1
View File
@@ -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,
];
+3
View File
@@ -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(
+50
View File
@@ -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", "<x>"] })).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 =
'<input id="i"><div contenteditable="true"><span id="s">x</span></div><div id="d"></div>';
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 =
'<button id="b">b</button><div role="menuitem"><span id="m">m</span></div><p id="p"></p>';
expect(isInteractiveTarget(document.getElementById("b"))).toBe(true);
expect(isInteractiveTarget(document.getElementById("m"))).toBe(true);
expect(isInteractiveTarget(document.getElementById("p"))).toBe(false);
});
});
+159
View File
@@ -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<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>();
};
}
+2
View File
@@ -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<InCallViewProps> = ({
// [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
+4
View File
@@ -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;