Adds keyboard shortcuts for calling

This commit is contained in:
Josh Perez 2022-05-10 14:14:08 -04:00 committed by GitHub
parent 1b052ad16b
commit fa7b7fcd08
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 311 additions and 90 deletions

View File

@ -3133,6 +3133,30 @@
"message": "Toggle video on and off",
"description": "Shown in the shortcuts guide"
},
"Keyboard--accept-video-call": {
"message": "Accept call with video",
"description": "Shown in the calling keyboard shortcuts guide"
},
"Keyboard--accept-audio-call": {
"message": "Accept call with audio",
"description": "Shown in the calling keyboard shortcuts guide"
},
"Keyboard--start-audio-call": {
"message": "Start audio call",
"description": "Shown in the calling keyboard shortcuts guide"
},
"Keyboard--start-video-call": {
"message": "Start video call",
"description": "Shown in the calling keyboard shortcuts guide"
},
"Keyboard--decline-call": {
"message": "Decline call",
"description": "Shown in the calling keyboard shortcuts guide"
},
"Keyboard--hang-up": {
"message": "End call",
"description": "Shown in the calling keyboard shortcuts guide"
},
"close-popup": {
"message": "Close Popup",
"description": "Used as alt text for any button closing a popup"

View File

@ -39,6 +39,10 @@ import { missingCaseError } from '../util/missingCaseError';
import * as KeyboardLayout from '../services/keyboardLayout';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { CallingAudioIndicator } from './CallingAudioIndicator';
import {
useActiveCallShortcuts,
useKeyboardShortcuts,
} from '../hooks/useKeyboardShortcuts';
export type PropsType = {
activeCall: ActiveCallType;
@ -140,6 +144,9 @@ export const CallScreen: React.FC<PropsType> = ({
toggleSpeakerView
);
const activeCallShortcuts = useActiveCallShortcuts(hangUpActiveCall);
useKeyboardShortcuts(activeCallShortcuts);
const toggleAudio = useCallback(() => {
setLocalAudio({
enabled: !hasLocalAudio,

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { ReactChild } from 'react';
import React, { useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { Avatar } from './Avatar';
import { Tooltip } from './Tooltip';
import { Intl } from './Intl';
@ -16,6 +16,10 @@ import { CallMode } from '../types/Calling';
import type { ConversationType } from '../state/ducks/conversations';
import type { AcceptCallType, DeclineCallType } from '../state/ducks/calling';
import { missingCaseError } from '../util/missingCaseError';
import {
useIncomingCallShortcuts,
useKeyboardShortcuts,
} from '../hooks/useKeyboardShortcuts';
export type PropsType = {
acceptCall: (_: AcceptCallType) => void;
@ -222,6 +226,25 @@ export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
};
}, [bounceAppIconStart, bounceAppIconStop]);
const acceptVideoCall = useCallback(() => {
acceptCall({ conversationId, asVideoCall: true });
}, [acceptCall, conversationId]);
const acceptAudioCall = useCallback(() => {
acceptCall({ conversationId, asVideoCall: false });
}, [acceptCall, conversationId]);
const declineIncomingCall = useCallback(() => {
declineCall({ conversationId });
}, [conversationId, declineCall]);
const incomingCallShortcuts = useIncomingCallShortcuts(
acceptAudioCall,
acceptVideoCall,
declineIncomingCall
);
useKeyboardShortcuts(incomingCallShortcuts);
return (
<div className="IncomingCallBar__container">
<div className="IncomingCallBar__bar">
@ -259,9 +282,7 @@ export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
<div className="IncomingCallBar__actions">
<CallButton
classSuffix="decline"
onClick={() => {
declineCall({ conversationId });
}}
onClick={declineIncomingCall}
tabIndex={0}
tooltipContent={i18n('declineCall')}
/>
@ -269,17 +290,13 @@ export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
<>
<CallButton
classSuffix="accept-video-as-audio"
onClick={() => {
acceptCall({ conversationId, asVideoCall: false });
}}
onClick={acceptAudioCall}
tabIndex={0}
tooltipContent={i18n('acceptCallWithoutVideo')}
/>
<CallButton
classSuffix="accept-video"
onClick={() => {
acceptCall({ conversationId, asVideoCall: true });
}}
onClick={acceptVideoCall}
tabIndex={0}
tooltipContent={i18n('acceptCall')}
/>
@ -287,9 +304,7 @@ export const IncomingCallBar = (props: PropsType): JSX.Element | null => {
) : (
<CallButton
classSuffix="accept-audio"
onClick={() => {
acceptCall({ conversationId, asVideoCall: false });
}}
onClick={acceptAudioCall}
tabIndex={0}
tooltipContent={i18n('acceptCall')}
/>

View File

@ -15,6 +15,7 @@ export type Props = {
type KeyType =
| 'commandOrCtrl'
| 'ctrlOrAlt'
| 'optionOrAlt'
| 'shift'
| 'enter'
@ -40,6 +41,7 @@ type KeyType =
| 'U'
| 'V'
| 'X'
| 'Y'
| '1 to 9';
type ShortcutType = {
description: string;
@ -202,6 +204,30 @@ const CALLING_SHORTCUTS: Array<ShortcutType> = [
description: 'Keyboard--toggle-video',
keys: [['shift', 'V']],
},
{
description: 'Keyboard--accept-video-call',
keys: [['ctrlOrAlt', 'shift', 'V']],
},
{
description: 'Keyboard--accept-audio-call',
keys: [['ctrlOrAlt', 'shift', 'A']],
},
{
description: 'Keyboard--decline-call',
keys: [['ctrlOrAlt', 'shift', 'D']],
},
{
description: 'Keyboard--start-audio-call',
keys: [['ctrlOrAlt', 'shift', 'C']],
},
{
description: 'Keyboard--start-video-call',
keys: [['ctrlOrAlt', 'shift', 'Y']],
},
{
description: 'Keyboard--hang-up',
keys: [['ctrlOrAlt', 'shift', 'E']],
},
];
export const ShortcutGuide = (props: Props): JSX.Element => {
@ -312,6 +338,14 @@ function renderShortcut(
label = i18n('Keyboard--Key--ctrl');
isSquare = false;
}
if (key === 'ctrlOrAlt' && isMacOS) {
label = i18n('Keyboard--Key--ctrl');
isSquare = false;
}
if (key === 'ctrlOrAlt' && !isMacOS) {
label = i18n('Keyboard--Key--alt');
isSquare = false;
}
if (key === 'optionOrAlt' && isMacOS) {
label = i18n('Keyboard--Key--option');
isSquare = false;

View File

@ -24,6 +24,10 @@ import { getMuteOptions } from '../../util/getMuteOptions';
import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import {
useStartCallShortcuts,
useKeyboardShortcuts,
} from '../../hooks/useKeyboardShortcuts';
export enum OutgoingCallButtonStyle {
None,
@ -297,80 +301,6 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
);
}
private renderOutgoingCallButtons(): ReactNode {
const {
announcementsOnly,
areWeAdmin,
i18n,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
outgoingCallButtonStyle,
showBackButton,
} = this.props;
const { isNarrow } = this.state;
const videoButton = (
<button
aria-label={i18n('makeOutgoingVideoCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--video',
showBackButton ? null : 'module-ConversationHeader__button--show',
!showBackButton && announcementsOnly && !areWeAdmin
? 'module-ConversationHeader__button--show-disabled'
: undefined
)}
disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation}
type="button"
/>
);
switch (outgoingCallButtonStyle) {
case OutgoingCallButtonStyle.None:
return null;
case OutgoingCallButtonStyle.JustVideo:
return videoButton;
case OutgoingCallButtonStyle.Both:
return (
<>
{videoButton}
<button
type="button"
onClick={onOutgoingAudioCallInConversation}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--audio',
showBackButton
? null
: 'module-ConversationHeader__button--show'
)}
disabled={showBackButton}
aria-label={i18n('makeOutgoingCall')}
/>
</>
);
case OutgoingCallButtonStyle.Join:
return (
<button
aria-label={i18n('joinOngoingCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--join-call',
showBackButton ? null : 'module-ConversationHeader__button--show'
)}
disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation}
type="button"
>
{isNarrow ? null : i18n('joinOngoingCall')}
</button>
);
default:
throw missingCaseError(outgoingCallButtonStyle);
}
}
private renderMenu(triggerId: string): ReactNode {
const {
acceptedMessageRequest,
@ -588,8 +518,19 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
}
public override render(): ReactNode {
const { id, isSMSOnly, i18n, onSetDisappearingMessages, expireTimer } =
this.props;
const {
announcementsOnly,
areWeAdmin,
expireTimer,
i18n,
id,
isSMSOnly,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onSetDisappearingMessages,
outgoingCallButtonStyle,
showBackButton,
} = this.props;
const { isNarrow, modalState } = this.state;
const triggerId = `conversation-${id}`;
@ -633,7 +574,22 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
>
{this.renderBackButton()}
{this.renderHeader()}
{!isSMSOnly && this.renderOutgoingCallButtons()}
{!isSMSOnly && (
<OutgoingCallButtons
announcementsOnly={announcementsOnly}
areWeAdmin={areWeAdmin}
i18n={i18n}
isNarrow={isNarrow}
onOutgoingAudioCallInConversation={
onOutgoingAudioCallInConversation
}
onOutgoingVideoCallInConversation={
onOutgoingVideoCallInConversation
}
outgoingCallButtonStyle={outgoingCallButtonStyle}
showBackButton={showBackButton}
/>
)}
{this.renderSearchButton()}
{this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)}
@ -644,3 +600,88 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
);
}
}
function OutgoingCallButtons({
announcementsOnly,
areWeAdmin,
i18n,
isNarrow,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
outgoingCallButtonStyle,
showBackButton,
}: { isNarrow: boolean } & Pick<
PropsType,
| 'announcementsOnly'
| 'areWeAdmin'
| 'i18n'
| 'onOutgoingAudioCallInConversation'
| 'onOutgoingVideoCallInConversation'
| 'outgoingCallButtonStyle'
| 'showBackButton'
>): JSX.Element | null {
const videoButton = (
<button
aria-label={i18n('makeOutgoingVideoCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--video',
showBackButton ? null : 'module-ConversationHeader__button--show',
!showBackButton && announcementsOnly && !areWeAdmin
? 'module-ConversationHeader__button--show-disabled'
: undefined
)}
disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation}
type="button"
/>
);
const startCallShortcuts = useStartCallShortcuts(
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation
);
useKeyboardShortcuts(startCallShortcuts);
switch (outgoingCallButtonStyle) {
case OutgoingCallButtonStyle.None:
return null;
case OutgoingCallButtonStyle.JustVideo:
return videoButton;
case OutgoingCallButtonStyle.Both:
return (
<>
{videoButton}
<button
type="button"
onClick={onOutgoingAudioCallInConversation}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--audio',
showBackButton ? null : 'module-ConversationHeader__button--show'
)}
disabled={showBackButton}
aria-label={i18n('makeOutgoingCall')}
/>
</>
);
case OutgoingCallButtonStyle.Join:
return (
<button
aria-label={i18n('joinOngoingCall')}
className={classNames(
'module-ConversationHeader__button',
'module-ConversationHeader__button--join-call',
showBackButton ? null : 'module-ConversationHeader__button--show'
)}
disabled={showBackButton}
onClick={onOutgoingVideoCallInConversation}
type="button"
>
{isNarrow ? null : i18n('joinOngoingCall')}
</button>
);
default:
throw missingCaseError(outgoingCallButtonStyle);
}
}

View File

@ -15,6 +15,106 @@ function isCmdOrCtrl(ev: KeyboardEvent): boolean {
return commandKey || controlKey;
}
function isCtrlOrAlt(ev: KeyboardEvent): boolean {
const { altKey, ctrlKey } = ev;
const controlKey = get(window, 'platform') === 'darwin' && ctrlKey;
const theAltKey = get(window, 'platform') !== 'darwin' && altKey;
return controlKey || theAltKey;
}
export function useActiveCallShortcuts(
hangUp: () => unknown
): KeyboardShortcutHandlerType {
return useCallback(
ev => {
const { shiftKey } = ev;
const key = KeyboardLayout.lookup(ev);
if (isCtrlOrAlt(ev) && shiftKey && (key === 'e' || key === 'E')) {
ev.preventDefault();
ev.stopPropagation();
hangUp();
return true;
}
return false;
},
[hangUp]
);
}
export function useIncomingCallShortcuts(
acceptAudioCall: () => unknown,
acceptVideoCall: () => unknown,
declineCall: () => unknown
): KeyboardShortcutHandlerType {
return useCallback(
ev => {
const { shiftKey } = ev;
const key = KeyboardLayout.lookup(ev);
if (isCtrlOrAlt(ev) && shiftKey && (key === 'v' || key === 'V')) {
ev.preventDefault();
ev.stopPropagation();
acceptVideoCall();
return true;
}
if (isCtrlOrAlt(ev) && shiftKey && (key === 'a' || key === 'A')) {
ev.preventDefault();
ev.stopPropagation();
acceptAudioCall();
return true;
}
if (isCtrlOrAlt(ev) && shiftKey && (key === 'd' || key === 'D')) {
ev.preventDefault();
ev.stopPropagation();
declineCall();
return true;
}
return false;
},
[acceptAudioCall, acceptVideoCall, declineCall]
);
}
export function useStartCallShortcuts(
startAudioCall: () => unknown,
startVideoCall: () => unknown
): KeyboardShortcutHandlerType {
return useCallback(
ev => {
const { shiftKey } = ev;
const key = KeyboardLayout.lookup(ev);
if (isCtrlOrAlt(ev) && shiftKey && (key === 'c' || key === 'C')) {
ev.preventDefault();
ev.stopPropagation();
startAudioCall();
return true;
}
if (isCtrlOrAlt(ev) && shiftKey && (key === 'y' || key === 'Y')) {
ev.preventDefault();
ev.stopPropagation();
startVideoCall();
return true;
}
return false;
},
[startAudioCall, startVideoCall]
);
}
export function useStartRecordingShortcut(
startAudioRecording: () => unknown
): KeyboardShortcutHandlerType {