Merge pull request #3927 from element-hq/toger5/back-button-press-control-on-esc

Refactor of Escape keyboard event bahvior (allow mute/emoji shortcuts after closing a modal with escape)
This commit is contained in:
Timo
2026-05-05 00:39:53 +08:00
committed by GitHub
5 changed files with 80 additions and 32 deletions

View File

@@ -92,6 +92,11 @@ export const Modal: FC<Props> = ({
return ( return (
<Drawer.Root <Drawer.Root
open={open} open={open}
// This autofocus is a custom vault property and not the
// standard HTML autofocus attribute.
// It makes the Drawer.Root behave like the `DialogRoot`
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onOpenChange={onOpenChange} onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined} dismissible={onDismiss !== undefined}
> >

View File

@@ -227,10 +227,7 @@ export const InCallView: FC<InCallViewProps> = ({
const toggleVideo = useBehavior(muteStates.video.toggle$); const toggleVideo = useBehavior(muteStates.video.toggle$);
const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$); const setAudioEnabled = useBehavior(muteStates.audio.setEnabled$);
// 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!
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
containerRef1,
toggleAudio, toggleAudio,
toggleVideo, toggleVideo,
setAudioEnabled, setAudioEnabled,

View File

@@ -47,6 +47,7 @@ import { usePageTitle } from "../usePageTitle";
import { getValue } from "../utils/observable"; import { getValue } from "../utils/observable";
import { useBehavior } from "../useBehavior"; import { useBehavior } from "../useBehavior";
import { CallFooter } from "../components/CallFooter"; import { CallFooter } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
interface Props { interface Props {
client: MatrixClient; client: MatrixClient;
@@ -91,6 +92,11 @@ export const LobbyView: FC<Props> = ({
const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState(defaultSettingsTab); const [settingsTab, setSettingsTab] = useState(defaultSettingsTab);
// 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!
// Next to the keyboard shortcuts, this is also responsible for catching escape key presses and forwarding the to mobile -> pip.
useCallViewKeyboardShortcuts(toggleAudio, toggleVideo, null, null, null);
const openSettings = useCallback( const openSettings = useCallback(
() => setSettingsModalOpen(true), () => setSettingsModalOpen(true),
[setSettingsModalOpen], [setSettingsModalOpen],

View File

@@ -6,7 +6,7 @@ Please see LICENSE in the repository root for full details.
*/ */
import { render } from "@testing-library/react"; import { render } from "@testing-library/react";
import { type FC, useRef, useState } from "react"; import { type FC, useState } from "react";
import { expect, test, vi } from "vitest"; import { expect, test, vi } from "vitest";
import { Button } from "@vector-im/compound-web"; import { Button } from "@vector-im/compound-web";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -39,9 +39,7 @@ const TestComponent: FC<TestComponentProps> = ({
initialModalOpen = false, initialModalOpen = false,
}) => { }) => {
const [modalOpen, setModalOpen] = useState(initialModalOpen); const [modalOpen, setModalOpen] = useState(initialModalOpen);
const ref = useRef<HTMLDivElement | null>(null);
useCallViewKeyboardShortcuts( useCallViewKeyboardShortcuts(
ref,
() => {}, () => {},
() => {}, () => {},
setAudioEnabled, setAudioEnabled,
@@ -49,8 +47,11 @@ const TestComponent: FC<TestComponentProps> = ({
toggleHandRaised, toggleHandRaised,
); );
return ( return (
<div ref={ref}> <>
<Button onClick={onButtonClick}>TEST</Button> <div id={initialModalOpen ? "root" : undefined}>
<Button onClick={onButtonClick}>TEST</Button>
</div>
{/*// modal lives outside of the root*/}
{modalOpen && ( {modalOpen && (
<dialog <dialog
open open
@@ -64,7 +65,7 @@ const TestComponent: FC<TestComponentProps> = ({
<button>InModalButton</button> <button>InModalButton</button>
</dialog> </dialog>
)} )}
</div> </>
); );
}; };
@@ -164,12 +165,13 @@ test("unmuting happens in place of the default action", async () => {
// container element that can be interactive and receive focus / keydown // container element that can be interactive and receive focus / keydown
// events. <video> is kind of a weird choice, but it'll do the job. // events. <video> is kind of a weird choice, but it'll do the job.
render( render(
<video <div id="root">
tabIndex={0} <video
onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())} tabIndex={0}
> onKeyDown={(e) => defaultPrevented(e.isDefaultPrevented())}
<TestComponent setAudioEnabled={() => {}} /> />
</video>, <TestComponent setAudioEnabled={() => {}} />,
</div>,
); );
await user.tab(); // Focus the <video> await user.tab(); // Focus the <video>

View File

@@ -5,7 +5,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details. Please see LICENSE in the repository root for full details.
*/ */
import { type RefObject, useCallback, useMemo, useRef } from "react"; import { useCallback, useMemo, useRef } from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { useEventTarget } from "./useEvents"; import { useEventTarget } from "./useEvents";
import { import {
@@ -18,22 +19,61 @@ import {
* Determines whether focus is in the same part of the tree as the given * 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). * element (specifically, if the element or an ancestor of it is focused).
*/ */
const mayReceiveKeyEvents = (e: HTMLElement): boolean => { const mayReceiveKeyEvents = (): boolean => {
const focusedElement = document.activeElement; const root = document.getElementById("root");
return focusedElement !== null && focusedElement.contains(e); 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( const KeyToReactionMap: Record<string, ReactionOption> = Object.fromEntries(
ReactionSet.slice(0, ReactionsRowSize).map((r, i) => [(i + 1).toString(), r]), 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( export function useCallViewKeyboardShortcuts(
focusElement: RefObject<HTMLElement | null>,
toggleAudio: (() => void) | null, toggleAudio: (() => void) | null,
toggleVideo: (() => void) | null, toggleVideo: (() => void) | null,
setAudioEnabled: ((enabled: boolean) => void) | null, setAudioEnabled: ((enabled: boolean) => void) | null,
sendReaction: (reaction: ReactionOption) => void, sendReaction: ((reaction: ReactionOption) => void) | null,
toggleHandRaised: () => void, toggleHandRaised: (() => void) | null,
): void { ): void {
const spacebarHeld = useRef(false); const spacebarHeld = useRef(false);
@@ -45,8 +85,8 @@ export function useCallViewKeyboardShortcuts(
"keydown", "keydown",
useCallback( useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (focusElement.current === null) return; logger.info("Keydown event", event);
if (!mayReceiveKeyEvents(focusElement.current)) return; if (!mayReceiveKeyEvents()) return;
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return; return;
@@ -56,7 +96,7 @@ export function useCallViewKeyboardShortcuts(
} else if (event.key === "v") { } else if (event.key === "v") {
event.preventDefault(); event.preventDefault();
toggleVideo?.(); toggleVideo?.();
} else if (event.key === " ") { } else if (event.key === " " && mayReceiveSpaceKeyEvents()) {
event.preventDefault(); event.preventDefault();
if (!spacebarHeld.current) { if (!spacebarHeld.current) {
spacebarHeld.current = true; spacebarHeld.current = true;
@@ -64,16 +104,16 @@ export function useCallViewKeyboardShortcuts(
} }
} else if (event.key === "h") { } else if (event.key === "h") {
event.preventDefault(); event.preventDefault();
toggleHandRaised(); toggleHandRaised?.();
} else if (KeyToReactionMap[event.key]) { } else if (KeyToReactionMap[event.key]) {
event.preventDefault(); event.preventDefault();
sendReaction(KeyToReactionMap[event.key]); sendReaction?.(KeyToReactionMap[event.key]);
} else if (event.key === "Escape") { } else if (event.key === "Escape") {
logger.info("Escape key pressed, triggering onBackButtonPressed");
window.controls.onBackButtonPressed?.(); window.controls.onBackButtonPressed?.();
} }
}, },
[ [
focusElement,
toggleVideo, toggleVideo,
toggleAudio, toggleAudio,
setAudioEnabled, setAudioEnabled,
@@ -92,15 +132,13 @@ export function useCallViewKeyboardShortcuts(
"keyup", "keyup",
useCallback( useCallback(
(event: KeyboardEvent) => { (event: KeyboardEvent) => {
if (focusElement.current === null) return; if (!mayReceiveKeyEvents() || !mayReceiveSpaceKeyEvents()) return;
if (!mayReceiveKeyEvents(focusElement.current)) return;
if (event.key === " ") { if (event.key === " ") {
spacebarHeld.current = false; spacebarHeld.current = false;
setAudioEnabled?.(false); setAudioEnabled?.(false);
} }
}, },
[focusElement, setAudioEnabled], [setAudioEnabled],
), ),
); );