Adds keyboard shortcuts for calling
This commit is contained in:
parent
1b052ad16b
commit
fa7b7fcd08
6 changed files with 311 additions and 90 deletions
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in a new issue