Files
element-call-Github/src/useCallViewKeyboardShortcuts.ts
T
Timo K.andClaude Fable 5.1 bc58aed0d7 Keep keyboard shortcuts within Element Call's root
The call's shortcuts were listened for on the window and allowed
whenever focus was inside the standalone app's `#root` — which, for a
component embedded in a host, is the host's own root, or nothing. So m,
v and space fired while the user typed in the host's composer, and two
Element Calls on one page both answered every key.

Listen on the element Element Call treats as its root instead (the body
standalone, so nothing changes there), and judge whether a key press is
spoken for by what has focus — a dialog or a text field — rather than by
where it sits in the DOM, since the modals are now portalled to that
same root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:52:34 +02:00

174 lines
5.8 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 { useRootElement } from "./RootElementContext";
import {
type ReactionOption,
ReactionSet,
ReactionsRowSize,
} from "./reactions";
/**
* Whether what has focus is something a key press belongs to, rather than
* being free for a shortcut: a dialog (the settings, the invite modal, the
* reaction picker), or anything the user types into.
*
* Judged by what the focused element is, not by where it sits in the DOM:
* Element Call's modals are portalled to whatever it treats as its root, which
* is the body for the standalone app but a container inside the host's page
* for the component.
*/
const focusIsClaimed = (): boolean => {
const active = document.activeElement;
if (active === null || active === document.body) return false;
if (active.closest("dialog, [role='dialog']") !== null) return true;
return isTextEntry(active);
};
const textInputTypes = new Set([
"text",
"search",
"email",
"url",
"password",
"number",
"tel",
]);
const isTextEntry = (element: Element): boolean => {
if (element instanceof HTMLTextAreaElement) return true;
if (element instanceof HTMLInputElement)
return textInputTypes.has(element.type);
return element instanceof HTMLElement && element.isContentEditable;
};
/**
* 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]),
);
/**
* Sets up the call's keyboard shortcuts.
*
* They are listened for on the element Element Call treats as its root — the
* page, standalone, or the container a host mounted it in — so that a key
* pressed anywhere else on a host's page is none of Element Call's business,
* and two Element Calls on one page each only hear their own. Key presses that
* belong to something else — a dialog, a text field — are left alone.
*
* The following shortcuts are supported (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);
const rootElement = useRootElement();
// Listened for on the root rather than on what has focus, so that the user
// need not focus anything in particular for a shortcut to work: a key pressed
// with nothing focused reaches the body, which is the root standalone.
useEventTarget(
rootElement,
"keydown",
useCallback(
(event: KeyboardEvent) => {
logger.info("Keydown event", event);
if (focusIsClaimed()) 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 an ancestor, 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(
rootElement,
"keyup",
useCallback(
(event: KeyboardEvent) => {
if (focusIsClaimed() || !mayReceiveSpaceKeyEvents()) return;
if (event.key === " ") {
spacebarHeld.current = false;
setAudioEnabled?.(false);
}
},
[setAudioEnabled],
),
);
// Losing the window is what releases a held spacebar, wherever we are in it
useEventTarget(
window,
"blur",
useCallback(() => {
if (spacebarHeld.current) {
spacebarHeld.current = false;
setAudioEnabled?.(true);
}
}, [setAudioEnabled, spacebarHeld]),
);
}