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", "message": "Toggle video on and off",
"description": "Shown in the shortcuts guide" "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": { "close-popup": {
"message": "Close Popup", "message": "Close Popup",
"description": "Used as alt text for any button closing a 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 * as KeyboardLayout from '../services/keyboardLayout';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting'; import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { CallingAudioIndicator } from './CallingAudioIndicator'; import { CallingAudioIndicator } from './CallingAudioIndicator';
import {
useActiveCallShortcuts,
useKeyboardShortcuts,
} from '../hooks/useKeyboardShortcuts';
export type PropsType = { export type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
@ -140,6 +144,9 @@ export const CallScreen: React.FC<PropsType> = ({
toggleSpeakerView toggleSpeakerView
); );
const activeCallShortcuts = useActiveCallShortcuts(hangUpActiveCall);
useKeyboardShortcuts(activeCallShortcuts);
const toggleAudio = useCallback(() => { const toggleAudio = useCallback(() => {
setLocalAudio({ setLocalAudio({
enabled: !hasLocalAudio, enabled: !hasLocalAudio,

View file

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

View file

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

View file

@ -24,6 +24,10 @@ import { getMuteOptions } from '../../util/getMuteOptions';
import * as expirationTimer from '../../util/expirationTimer'; import * as expirationTimer from '../../util/expirationTimer';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { isInSystemContacts } from '../../util/isInSystemContacts'; import { isInSystemContacts } from '../../util/isInSystemContacts';
import {
useStartCallShortcuts,
useKeyboardShortcuts,
} from '../../hooks/useKeyboardShortcuts';
export enum OutgoingCallButtonStyle { export enum OutgoingCallButtonStyle {
None, 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 { private renderMenu(triggerId: string): ReactNode {
const { const {
acceptedMessageRequest, acceptedMessageRequest,
@ -588,8 +518,19 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
} }
public override render(): ReactNode { public override render(): ReactNode {
const { id, isSMSOnly, i18n, onSetDisappearingMessages, expireTimer } = const {
this.props; announcementsOnly,
areWeAdmin,
expireTimer,
i18n,
id,
isSMSOnly,
onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation,
onSetDisappearingMessages,
outgoingCallButtonStyle,
showBackButton,
} = this.props;
const { isNarrow, modalState } = this.state; const { isNarrow, modalState } = this.state;
const triggerId = `conversation-${id}`; const triggerId = `conversation-${id}`;
@ -633,7 +574,22 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
> >
{this.renderBackButton()} {this.renderBackButton()}
{this.renderHeader()} {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.renderSearchButton()}
{this.renderMoreButton(triggerId)} {this.renderMoreButton(triggerId)}
{this.renderMenu(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; 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( export function useStartRecordingShortcut(
startAudioRecording: () => unknown startAudioRecording: () => unknown
): KeyboardShortcutHandlerType { ): KeyboardShortcutHandlerType {