Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfb72bd9a5 | ||
|
|
e0eecc5271 |
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user