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
160 lines
5.5 KiB
TypeScript
160 lines
5.5 KiB
TypeScript
/*
|
|
Copyright 2022-2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { useCallback, useMemo, useRef } from "react";
|
|
import { logger } from "matrix-js-sdk/lib/logger";
|
|
|
|
import { useEventTarget } from "./useEvents";
|
|
import {
|
|
type ReactionOption,
|
|
ReactionSet,
|
|
ReactionsRowSize,
|
|
} from "./reactions";
|
|
import { isLotusHostHotkey } from "./lotus/lotusHotkeys";
|
|
|
|
/**
|
|
* Determines whether focus is in the same part of the tree as the given
|
|
* element (specifically, if the element or an ancestor of it is focused).
|
|
*/
|
|
const mayReceiveKeyEvents = (): boolean => {
|
|
const root = document.getElementById("root");
|
|
if (root === null) {
|
|
logger.warn(
|
|
"[mayReceiveKeyEvents] Root element not found, always allow keyboard shortcuts (m,v,esc...)",
|
|
);
|
|
return true;
|
|
}
|
|
const focusElement = document.activeElement;
|
|
const nothingInFocus = focusElement === null;
|
|
const focusOnBody = focusElement === document.body;
|
|
const noPrimaryFocus =
|
|
nothingInFocus || root.contains(focusElement) || focusOnBody;
|
|
|
|
logger.warn(
|
|
`[mayReceiveKeyEvents] nothingInFocus ${nothingInFocus}, focusOnBody ${focusOnBody}, noPrimaryFocus ${noPrimaryFocus}`,
|
|
);
|
|
// Only if we do not have a primary focus we allow keyboard shortcut events.
|
|
return noPrimaryFocus;
|
|
};
|
|
|
|
/**
|
|
* Only do push to talk behavior if the active element is not a button or button like.
|
|
*/
|
|
const mayReceiveSpaceKeyEvents = (): boolean => {
|
|
const activeElement = document.activeElement;
|
|
if (activeElement === null) return true;
|
|
return activeElement.tagName.toLowerCase() !== "button";
|
|
};
|
|
|
|
const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
|
|
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]),
|
|
);
|
|
|
|
/**
|
|
* This hook sets up gloabl keyboard shortcuts. It will filter for keyboard presses that should be ignored due to user
|
|
* currently focussing on a modal.
|
|
* This is achieved by using the fact, that all modal inputs are outside the #root element and use react portals to get rendered.
|
|
* The following shortcuts are auspported (optional):
|
|
* @param toggleAudio - triggered on (m)
|
|
* @param toggleVideo - triggered on (v)
|
|
* @param setAudioEnabled - push to talk behavior controlled via (space)
|
|
* @param sendReaction - triggered on (1,2,3,...)
|
|
* @param toggleHandRaised - triggered on (h)
|
|
* Additionally this method listens to the (escape) key to trigger the onBackButtonPressed callback, which is used to navigate to pip in the native app.
|
|
*
|
|
* Note: This function incorrectly assumes that there is a camera and microphone, which is not always the case.
|
|
*/
|
|
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
|
|
export function useCallViewKeyboardShortcuts(
|
|
toggleAudio: (() => void) | null,
|
|
toggleVideo: (() => void) | null,
|
|
setAudioEnabled: ((enabled: boolean) => void) | null,
|
|
sendReaction: ((reaction: ReactionOption) => void) | null,
|
|
toggleHandRaised: (() => void) | null,
|
|
): void {
|
|
const spacebarHeld = useRef(false);
|
|
|
|
// These event handlers are set on the window because we want users to be able
|
|
// to trigger them without going to the trouble of focusing something
|
|
|
|
useEventTarget(
|
|
window,
|
|
"keydown",
|
|
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;
|
|
|
|
if (event.key === "m") {
|
|
event.preventDefault();
|
|
toggleAudio?.();
|
|
} else if (event.key === "v") {
|
|
event.preventDefault();
|
|
toggleVideo?.();
|
|
} else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
|
|
event.preventDefault();
|
|
if (!spacebarHeld.current) {
|
|
spacebarHeld.current = true;
|
|
setAudioEnabled?.(true);
|
|
}
|
|
} else if (event.key === "h") {
|
|
event.preventDefault();
|
|
toggleHandRaised?.();
|
|
} else if (KeyToReactionMap[event.key]) {
|
|
event.preventDefault();
|
|
sendReaction?.(KeyToReactionMap[event.key]);
|
|
} else if (event.key === "Escape") {
|
|
logger.info("Escape key pressed, triggering onBackButtonPressed");
|
|
window.controls.onBackButtonPressed?.();
|
|
}
|
|
},
|
|
[
|
|
toggleVideo,
|
|
toggleAudio,
|
|
setAudioEnabled,
|
|
sendReaction,
|
|
toggleHandRaised,
|
|
],
|
|
),
|
|
// Because this is set on the window, to prevent shortcuts from activating
|
|
// another event callback at the same time, we need to preventDefault
|
|
// *before* child elements receive the event by using capture mode
|
|
useMemo(() => ({ capture: true }), []),
|
|
);
|
|
|
|
useEventTarget(
|
|
window,
|
|
"keyup",
|
|
useCallback(
|
|
(event: KeyboardEvent) => {
|
|
if (isLotusHostHotkey(event.code)) return;
|
|
if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
|
|
if (event.key === " ") {
|
|
spacebarHeld.current = false;
|
|
setAudioEnabled?.(false);
|
|
}
|
|
},
|
|
[setAudioEnabled],
|
|
),
|
|
);
|
|
|
|
useEventTarget(
|
|
window,
|
|
"blur",
|
|
useCallback(() => {
|
|
if (spacebarHeld.current) {
|
|
spacebarHeld.current = false;
|
|
setAudioEnabled?.(true);
|
|
}
|
|
}, [setAudioEnabled, spacebarHeld]),
|
|
);
|
|
}
|