Files
element-call/src/useCallViewKeyboardShortcuts.ts
T

174 lines
5.8 KiB
TypeScript
Raw Normal View History

/*
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.
*/
2026-04-27 18:15:29 +02:00
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;
2023-04-19 15:54:39 -04:00
};
2026-04-27 18:15:29 +02:00
/**
* 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.
2026-05-04 18:25:04 +02:00
*
* Note: This function incorrectly assumes that there is a camera and microphone, which is not always the case.
*/
2026-05-04 18:25:04 +02:00
// TODO: Make sure that this module is resilient when it comes to camera/microphone availability!
2023-01-13 11:52:40 +00:00
export function useCallViewKeyboardShortcuts(
2025-08-29 18:46:24 +02:00
toggleAudio: (() => void) | null,
toggleVideo: (() => void) | null,
setAudioEnabled: ((enabled: boolean) => void) | null,
sendReaction: ((reaction: ReactionOption) => void) | null,
toggleHandRaised: (() => void) | null,
2023-09-22 18:05:13 -04:00
): void {
2023-04-19 15:17:32 -04:00
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") {
2024-07-24 18:42:21 -04:00
event.preventDefault();
2025-08-29 18:46:24 +02:00
toggleAudio?.();
} else if (event.key === "v") {
2024-07-24 18:42:21 -04:00
event.preventDefault();
2025-08-29 18:46:24 +02:00
toggleVideo?.();
2026-04-27 18:15:29 +02:00
} else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
2024-07-24 18:42:21 -04:00
event.preventDefault();
if (!spacebarHeld.current) {
spacebarHeld.current = true;
2025-08-29 18:46:24 +02:00
setAudioEnabled?.(true);
2024-07-24 18:42:21 -04:00
}
} else if (event.key === "h") {
event.preventDefault();
toggleHandRaised?.();
} else if (KeyToReactionMap[event.key]) {
event.preventDefault();
sendReaction?.(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") {
2026-04-28 13:49:36 +02:00
logger.info("Escape key pressed, triggering onBackButtonPressed");
window.controls.onBackButtonPressed?.();
}
},
2023-04-19 15:55:55 -04:00
[
2025-08-29 18:46:24 +02:00
toggleVideo,
toggleAudio,
setAudioEnabled,
sendReaction,
toggleHandRaised,
2023-10-11 10:42:04 -04:00
],
),
// Because this is set on an ancestor, to prevent shortcuts from activating
2024-07-24 18:42:21 -04:00
// 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 === " ") {
2023-04-19 15:17:32 -04:00
spacebarHeld.current = false;
2025-08-29 18:46:24 +02:00
setAudioEnabled?.(false);
}
},
[setAudioEnabled],
2023-10-11 10:42:04 -04:00
),
);
// Losing the window is what releases a held spacebar, wherever we are in it
useEventTarget(
window,
"blur",
useCallback(() => {
2023-04-21 10:18:43 +01:00
if (spacebarHeld.current) {
2023-04-19 15:17:32 -04:00
spacebarHeld.current = false;
2025-08-29 18:46:24 +02:00
setAudioEnabled?.(true);
}
2025-08-29 18:46:24 +02:00
}, [setAudioEnabled, spacebarHeld]),
);
}