2023-01-12 17:31:19 +00:00
/*
2024-09-06 10:22:13 +02:00
Copyright 2022-2024 New Vector Ltd.
2023-01-12 17:31:19 +00:00
2025-02-18 17:59:58 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-06 10:22:13 +02:00
Please see LICENSE in the repository root for full details.
2023-01-12 17:31:19 +00:00
*/
2026-04-27 18:15:29 +02:00
import { useCallback , useMemo , useRef } from "react" ;
2026-04-27 14:20:13 +02:00
import { logger } from "matrix-js-sdk/lib/logger" ;
2023-01-12 17:31:19 +00:00
import { useEventTarget } from "./useEvents" ;
2026-09-08 13:52:34 +02:00
import { useRootElement } from "./RootElementContext" ;
2024-12-11 09:27:55 +00:00
import {
type ReactionOption ,
ReactionSet ,
ReactionsRowSize ,
} from "./reactions" ;
2023-01-12 17:31:19 +00:00
2023-04-19 15:51:44 -04:00
/**
2026-09-08 13:52:34 +02:00
* 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.
2023-04-19 15:51:44 -04:00
*/
2026-09-08 13:52:34 +02:00
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 );
};
2026-04-27 14:20:13 +02:00
2026-09-08 13:52:34 +02:00
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
};
2023-04-19 15:51:44 -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" ;
};
2024-11-19 16:57:57 +00:00
const KeyToReactionMap : Record < string , ReactionOption > = Object . fromEntries (
ReactionSet . slice ( 0 , ReactionsRowSize ). map (( r , i ) => [( i + 1 ). toString (), r ]),
);
2026-04-27 14:20:13 +02:00
/**
2026-09-08 13:52:34 +02:00
* 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):
2026-04-27 14:20:13 +02:00
* @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-04-27 14:20:13 +02:00
*/
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 ,
2026-04-27 14:20:13 +02:00
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 );
2026-09-08 13:52:34 +02:00
const rootElement = useRootElement ();
2023-01-12 17:31:19 +00:00
2026-09-08 13:52:34 +02:00
// 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.
2023-04-19 15:51:44 -04:00
2023-01-12 17:31:19 +00:00
useEventTarget (
2026-09-08 13:52:34 +02:00
rootElement ,
2023-01-12 17:31:19 +00:00
"keydown" ,
useCallback (
( event : KeyboardEvent ) => {
2026-04-27 14:20:13 +02:00
logger . info ( "Keydown event" , event );
2026-09-08 13:52:34 +02:00
if ( focusIsClaimed ()) return ;
2024-11-22 13:17:05 -05:00
if ( event . altKey || event . ctrlKey || event . metaKey || event . shiftKey )
return ;
2023-01-12 17:31:19 +00:00
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
}
2024-11-19 16:57:57 +00:00
} else if ( event . key === "h" ) {
event . preventDefault ();
2026-04-27 14:20:13 +02:00
toggleHandRaised ? .();
2024-11-19 16:57:57 +00:00
} else if ( KeyToReactionMap [ event . key ]) {
event . preventDefault ();
2026-04-27 14:20:13 +02:00
sendReaction ? .( KeyToReactionMap [ event . key ]);
2026-04-22 11:51:33 +02:00
} else if ( event . key === "Escape" ) {
2026-04-28 13:49:36 +02:00
logger . info ( "Escape key pressed, triggering onBackButtonPressed" );
2026-04-22 11:51:33 +02:00
window . controls . onBackButtonPressed ? .();
2023-01-12 17:31:19 +00:00
}
},
2023-04-19 15:55:55 -04:00
[
2025-08-29 18:46:24 +02:00
toggleVideo ,
toggleAudio ,
setAudioEnabled ,
2024-11-19 16:57:57 +00:00
sendReaction ,
toggleHandRaised ,
2023-10-11 10:42:04 -04:00
],
),
2026-09-08 13:52:34 +02: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 }), []),
2023-01-12 17:31:19 +00:00
);
useEventTarget (
2026-09-08 13:52:34 +02:00
rootElement ,
2023-01-12 17:31:19 +00:00
"keyup" ,
useCallback (
( event : KeyboardEvent ) => {
2026-09-08 13:52:34 +02:00
if ( focusIsClaimed () || ! mayReceiveSpaceKeyEvents ()) return ;
2023-01-12 17:31:19 +00:00
if ( event . key === " " ) {
2023-04-19 15:17:32 -04:00
spacebarHeld . current = false ;
2025-08-29 18:46:24 +02:00
setAudioEnabled ? .( false );
2023-01-12 17:31:19 +00:00
}
},
2026-04-27 14:20:13 +02:00
[ setAudioEnabled ],
2023-10-11 10:42:04 -04:00
),
2023-01-12 17:31:19 +00:00
);
2026-09-08 13:52:34 +02:00
// Losing the window is what releases a held spacebar, wherever we are in it
2023-01-12 17:31:19 +00:00
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 );
2023-01-12 17:31:19 +00:00
}
2025-08-29 18:46:24 +02:00
}, [ setAudioEnabled , spacebarHeld ]),
2023-01-12 17:31:19 +00:00
);
}