Calling support
This commit is contained in:
parent
83574eb067
commit
d3a27a6442
72 changed files with 3864 additions and 191 deletions
65
ts/components/CallManager.stories.tsx
Normal file
65
ts/components/CallManager.stories.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import * as React from 'react';
|
||||
import { CallManager } from './CallManager';
|
||||
import { CallState } from '../types/Calling';
|
||||
import { ColorType } from '../types/Util';
|
||||
|
||||
// @ts-ignore
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
// @ts-ignore
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const callDetails = {
|
||||
avatarPath: undefined,
|
||||
callId: 0,
|
||||
contactColor: 'ultramarine' as ColorType,
|
||||
isIncoming: true,
|
||||
isVideoCall: true,
|
||||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
acceptCall: action('accept-call'),
|
||||
callDetails,
|
||||
callState: CallState.Accepted,
|
||||
declineCall: action('decline-call'),
|
||||
getVideoCapturer: () => ({}),
|
||||
getVideoRenderer: () => ({}),
|
||||
hangUp: action('hang-up'),
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
hasRemoteVideo: true,
|
||||
i18n,
|
||||
setLocalAudio: action('set-local-audio'),
|
||||
setLocalVideo: action('set-local-video'),
|
||||
setVideoCapturer: action('set-video-capturer'),
|
||||
setVideoRenderer: action('set-video-renderer'),
|
||||
};
|
||||
|
||||
const permutations = [
|
||||
{
|
||||
title: 'Call Manager (ongoing)',
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
title: 'Call Manager (ringing)',
|
||||
props: {
|
||||
callState: CallState.Ringing,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
storiesOf('Components/CallManager', module).add('Iterations', () => {
|
||||
return permutations.map(({ props, title }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<CallManager {...defaultProps} {...props} />
|
||||
</>
|
||||
));
|
||||
});
|
77
ts/components/CallManager.tsx
Normal file
77
ts/components/CallManager.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { CallScreen, PropsType as CallScreenPropsType } from './CallScreen';
|
||||
import {
|
||||
IncomingCallBar,
|
||||
PropsType as IncomingCallBarPropsType,
|
||||
} from './IncomingCallBar';
|
||||
import { CallState } from '../types/Calling';
|
||||
import { CallDetailsType } from '../state/ducks/calling';
|
||||
|
||||
type CallManagerPropsType = {
|
||||
callDetails?: CallDetailsType;
|
||||
callState?: CallState;
|
||||
};
|
||||
type PropsType = IncomingCallBarPropsType &
|
||||
CallScreenPropsType &
|
||||
CallManagerPropsType;
|
||||
|
||||
export const CallManager = ({
|
||||
acceptCall,
|
||||
callDetails,
|
||||
callState,
|
||||
declineCall,
|
||||
getVideoCapturer,
|
||||
getVideoRenderer,
|
||||
hangUp,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
hasRemoteVideo,
|
||||
i18n,
|
||||
setLocalAudio,
|
||||
setLocalVideo,
|
||||
setVideoCapturer,
|
||||
setVideoRenderer,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (!callDetails || !callState) {
|
||||
return null;
|
||||
}
|
||||
const incoming = callDetails.isIncoming;
|
||||
const outgoing = !incoming;
|
||||
const ongoing =
|
||||
callState === CallState.Accepted || callState === CallState.Reconnecting;
|
||||
const ringing = callState === CallState.Ringing;
|
||||
|
||||
if (outgoing || ongoing) {
|
||||
return (
|
||||
<CallScreen
|
||||
callDetails={callDetails}
|
||||
callState={callState}
|
||||
getVideoCapturer={getVideoCapturer}
|
||||
getVideoRenderer={getVideoRenderer}
|
||||
hangUp={hangUp}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
i18n={i18n}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
setVideoCapturer={setVideoCapturer}
|
||||
setVideoRenderer={setVideoRenderer}
|
||||
setLocalAudio={setLocalAudio}
|
||||
setLocalVideo={setLocalVideo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (incoming && ringing) {
|
||||
return (
|
||||
<IncomingCallBar
|
||||
acceptCall={acceptCall}
|
||||
callDetails={callDetails}
|
||||
declineCall={declineCall}
|
||||
i18n={i18n}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ended || (Incoming && Prering)
|
||||
return null;
|
||||
};
|
120
ts/components/CallScreen.stories.tsx
Normal file
120
ts/components/CallScreen.stories.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import * as React from 'react';
|
||||
import { CallState } from '../types/Calling';
|
||||
import { ColorType } from '../types/Util';
|
||||
import { CallScreen } from './CallScreen';
|
||||
|
||||
// @ts-ignore
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
// @ts-ignore
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, select } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const callDetails = {
|
||||
avatarPath: undefined,
|
||||
callId: 0,
|
||||
contactColor: 'ultramarine' as ColorType,
|
||||
isIncoming: true,
|
||||
isVideoCall: true,
|
||||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
callDetails,
|
||||
callState: CallState.Accepted,
|
||||
getVideoCapturer: () => ({}),
|
||||
getVideoRenderer: () => ({}),
|
||||
hangUp: action('hang-up'),
|
||||
hasLocalAudio: true,
|
||||
hasLocalVideo: true,
|
||||
hasRemoteVideo: true,
|
||||
i18n,
|
||||
setLocalAudio: action('set-local-audio'),
|
||||
setLocalVideo: action('set-local-video'),
|
||||
setVideoCapturer: action('set-video-capturer'),
|
||||
setVideoRenderer: action('set-video-renderer'),
|
||||
};
|
||||
|
||||
const permutations = [
|
||||
{
|
||||
title: 'Call Screen',
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
title: 'Call Screen (Pre-ring)',
|
||||
props: {
|
||||
callState: CallState.Prering,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Screen (Ringing)',
|
||||
props: {
|
||||
callState: CallState.Ringing,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Screen (Reconnecting)',
|
||||
props: {
|
||||
callState: CallState.Reconnecting,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Call Screen (Ended)',
|
||||
props: {
|
||||
callState: CallState.Ended,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Calling (no local audio)',
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasLocalAudio: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Calling (no local video)',
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasLocalVideo: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Calling (no remote video)',
|
||||
props: {
|
||||
...defaultProps,
|
||||
hasRemoteVideo: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
storiesOf('Components/CallScreen', module)
|
||||
.add('Knobs Playground', () => {
|
||||
const callState = select('callState', CallState, CallState.Accepted);
|
||||
const hasLocalAudio = boolean('hasLocalAudio', true);
|
||||
const hasLocalVideo = boolean('hasLocalVideo', true);
|
||||
const hasRemoteVideo = boolean('hasRemoteVideo', true);
|
||||
|
||||
return (
|
||||
<CallScreen
|
||||
{...defaultProps}
|
||||
callState={callState}
|
||||
hasLocalAudio={hasLocalAudio}
|
||||
hasLocalVideo={hasLocalVideo}
|
||||
hasRemoteVideo={hasRemoteVideo}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('Iterations', () => {
|
||||
return permutations.map(({ props, title }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<CallScreen {...defaultProps} {...props} />
|
||||
</>
|
||||
));
|
||||
});
|
380
ts/components/CallScreen.tsx
Normal file
380
ts/components/CallScreen.tsx
Normal file
|
@ -0,0 +1,380 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
CallDetailsType,
|
||||
HangUpType,
|
||||
SetLocalAudioType,
|
||||
SetLocalVideoType,
|
||||
SetVideoCapturerType,
|
||||
SetVideoRendererType,
|
||||
} from '../state/ducks/calling';
|
||||
import { Avatar } from './Avatar';
|
||||
import { CallState } from '../types/Calling';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import { CanvasVideoRenderer, GumVideoCapturer } from '../window.d';
|
||||
|
||||
type CallingButtonProps = {
|
||||
classNameSuffix: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const CallingButton = ({
|
||||
classNameSuffix,
|
||||
onClick,
|
||||
}: CallingButtonProps): JSX.Element => {
|
||||
const className = classNames(
|
||||
'module-ongoing-call__icon',
|
||||
`module-ongoing-call__icon${classNameSuffix}`
|
||||
);
|
||||
|
||||
return (
|
||||
<button className={className} onClick={onClick}>
|
||||
<div />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type PropsType = {
|
||||
callDetails?: CallDetailsType;
|
||||
callState?: CallState;
|
||||
getVideoCapturer: (
|
||||
ref: React.RefObject<HTMLVideoElement>
|
||||
) => GumVideoCapturer;
|
||||
getVideoRenderer: (
|
||||
ref: React.RefObject<HTMLCanvasElement>
|
||||
) => CanvasVideoRenderer;
|
||||
hangUp: (_: HangUpType) => void;
|
||||
hasLocalAudio: boolean;
|
||||
hasLocalVideo: boolean;
|
||||
hasRemoteVideo: boolean;
|
||||
i18n: LocalizerType;
|
||||
setLocalAudio: (_: SetLocalAudioType) => void;
|
||||
setLocalVideo: (_: SetLocalVideoType) => void;
|
||||
setVideoCapturer: (_: SetVideoCapturerType) => void;
|
||||
setVideoRenderer: (_: SetVideoRendererType) => void;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
acceptedTime: number | null;
|
||||
acceptedDuration: number | null;
|
||||
showControls: boolean;
|
||||
};
|
||||
|
||||
export class CallScreen extends React.Component<PropsType, StateType> {
|
||||
private interval: any;
|
||||
private controlsFadeTimer: any;
|
||||
private readonly localVideoRef: React.RefObject<HTMLVideoElement>;
|
||||
private readonly remoteVideoRef: React.RefObject<HTMLCanvasElement>;
|
||||
|
||||
constructor(props: PropsType) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
acceptedTime: null,
|
||||
acceptedDuration: null,
|
||||
showControls: true,
|
||||
};
|
||||
|
||||
this.interval = null;
|
||||
this.controlsFadeTimer = null;
|
||||
this.localVideoRef = React.createRef();
|
||||
this.remoteVideoRef = React.createRef();
|
||||
|
||||
this.setVideoCapturerAndRenderer(
|
||||
props.getVideoCapturer(this.localVideoRef),
|
||||
props.getVideoRenderer(this.remoteVideoRef)
|
||||
);
|
||||
}
|
||||
|
||||
public componentDidMount() {
|
||||
// It's really jump with a value of 500ms.
|
||||
this.interval = setInterval(this.updateAcceptedTimer, 100);
|
||||
this.fadeControls();
|
||||
|
||||
document.addEventListener('keydown', this.handleKeyDown);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
if (this.controlsFadeTimer) {
|
||||
clearTimeout(this.controlsFadeTimer);
|
||||
}
|
||||
this.setVideoCapturerAndRenderer(null, null);
|
||||
}
|
||||
|
||||
updateAcceptedTimer = () => {
|
||||
const { acceptedTime } = this.state;
|
||||
const { callState } = this.props;
|
||||
|
||||
if (acceptedTime) {
|
||||
this.setState({
|
||||
acceptedTime,
|
||||
acceptedDuration: Date.now() - acceptedTime,
|
||||
});
|
||||
} else if (
|
||||
callState === CallState.Accepted ||
|
||||
callState === CallState.Reconnecting
|
||||
) {
|
||||
this.setState({
|
||||
acceptedTime: Date.now(),
|
||||
acceptedDuration: 1,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleKeyDown = (event: KeyboardEvent) => {
|
||||
const { callDetails } = this.props;
|
||||
|
||||
if (!callDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
let eventHandled = false;
|
||||
|
||||
if (event.key === 'V') {
|
||||
this.toggleVideo();
|
||||
eventHandled = true;
|
||||
} else if (event.key === 'M') {
|
||||
this.toggleAudio();
|
||||
eventHandled = true;
|
||||
}
|
||||
|
||||
if (eventHandled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.showControls();
|
||||
}
|
||||
};
|
||||
|
||||
showControls = () => {
|
||||
if (!this.state.showControls) {
|
||||
this.setState({
|
||||
showControls: true,
|
||||
});
|
||||
}
|
||||
|
||||
this.fadeControls();
|
||||
};
|
||||
|
||||
fadeControls = () => {
|
||||
if (this.controlsFadeTimer) {
|
||||
clearTimeout(this.controlsFadeTimer);
|
||||
}
|
||||
|
||||
this.controlsFadeTimer = setTimeout(() => {
|
||||
this.setState({
|
||||
showControls: false,
|
||||
});
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
toggleAudio = () => {
|
||||
const { callDetails, hasLocalAudio, setLocalAudio } = this.props;
|
||||
|
||||
if (!callDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalAudio({
|
||||
callId: callDetails.callId,
|
||||
enabled: !hasLocalAudio,
|
||||
});
|
||||
};
|
||||
|
||||
toggleVideo = () => {
|
||||
const { callDetails, hasLocalVideo, setLocalVideo } = this.props;
|
||||
|
||||
if (!callDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalVideo({ callId: callDetails.callId, enabled: !hasLocalVideo });
|
||||
};
|
||||
|
||||
public render() {
|
||||
const {
|
||||
callDetails,
|
||||
callState,
|
||||
hangUp,
|
||||
hasLocalAudio,
|
||||
hasLocalVideo,
|
||||
hasRemoteVideo,
|
||||
} = this.props;
|
||||
const { showControls } = this.state;
|
||||
const isAudioOnly = !hasLocalVideo && !hasRemoteVideo;
|
||||
|
||||
if (!callDetails || !callState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controlsFadeClass = classNames({
|
||||
'module-ongoing-call__controls--fadeIn':
|
||||
(showControls || isAudioOnly) && callState !== CallState.Accepted,
|
||||
'module-ongoing-call__controls--fadeOut':
|
||||
!showControls && !isAudioOnly && callState === CallState.Accepted,
|
||||
});
|
||||
|
||||
const toggleAudioSuffix = hasLocalAudio
|
||||
? '--audio--enabled'
|
||||
: '--audio--disabled';
|
||||
const toggleVideoSuffix = hasLocalVideo
|
||||
? '--video--enabled'
|
||||
: '--video--disabled';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="module-ongoing-call"
|
||||
onMouseMove={this.showControls}
|
||||
role="group"
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'module-ongoing-call__header',
|
||||
controlsFadeClass
|
||||
)}
|
||||
>
|
||||
<div className="module-ongoing-call__header-name">
|
||||
{callDetails.name}
|
||||
</div>
|
||||
{this.renderMessage(callState)}
|
||||
</div>
|
||||
{hasRemoteVideo
|
||||
? this.renderRemoteVideo()
|
||||
: this.renderAvatar(callDetails)}
|
||||
{hasLocalVideo ? this.renderLocalVideo() : null}
|
||||
<div
|
||||
className={classNames(
|
||||
'module-ongoing-call__actions',
|
||||
controlsFadeClass
|
||||
)}
|
||||
>
|
||||
<CallingButton
|
||||
classNameSuffix={toggleVideoSuffix}
|
||||
onClick={this.toggleVideo}
|
||||
/>
|
||||
<CallingButton
|
||||
classNameSuffix={toggleAudioSuffix}
|
||||
onClick={this.toggleAudio}
|
||||
/>
|
||||
<CallingButton
|
||||
classNameSuffix="--hangup"
|
||||
onClick={() => {
|
||||
hangUp({ callId: callDetails.callId });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderAvatar(callDetails: CallDetailsType) {
|
||||
const { i18n } = this.props;
|
||||
const {
|
||||
avatarPath,
|
||||
contactColor,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
} = callDetails;
|
||||
return (
|
||||
<div className="module-ongoing-call__remote-video-disabled">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={contactColor || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
size={112}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderLocalVideo() {
|
||||
return (
|
||||
<video
|
||||
className="module-ongoing-call__local-video"
|
||||
ref={this.localVideoRef}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private renderRemoteVideo() {
|
||||
return (
|
||||
<canvas
|
||||
className="module-ongoing-call__remote-video-enabled"
|
||||
ref={this.remoteVideoRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
private renderMessage(callState: CallState) {
|
||||
const { i18n } = this.props;
|
||||
|
||||
let message = null;
|
||||
if (callState === CallState.Prering) {
|
||||
message = i18n('outgoingCallPrering');
|
||||
} else if (callState === CallState.Ringing) {
|
||||
message = i18n('outgoingCallRinging');
|
||||
} else if (callState === CallState.Reconnecting) {
|
||||
message = i18n('callReconnecting');
|
||||
} else if (
|
||||
callState === CallState.Accepted &&
|
||||
this.state.acceptedDuration
|
||||
) {
|
||||
message = i18n('callDuration', [
|
||||
this.renderDuration(this.state.acceptedDuration),
|
||||
]);
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
return <div className="module-ongoing-call__header-message">{message}</div>;
|
||||
}
|
||||
|
||||
private renderDuration(ms: number): string {
|
||||
const secs = Math.floor((ms / 1000) % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const mins = Math.floor((ms / 60000) % 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const hours = Math.floor(ms / 3600000);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${mins}:${secs}`;
|
||||
}
|
||||
return `${mins}:${secs}`;
|
||||
}
|
||||
|
||||
private setVideoCapturerAndRenderer(
|
||||
capturer: GumVideoCapturer | null,
|
||||
renderer: CanvasVideoRenderer | null
|
||||
) {
|
||||
const { callDetails, setVideoCapturer, setVideoRenderer } = this.props;
|
||||
|
||||
if (!callDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { callId } = callDetails;
|
||||
|
||||
setVideoCapturer({
|
||||
callId,
|
||||
capturer,
|
||||
});
|
||||
|
||||
setVideoRenderer({
|
||||
callId,
|
||||
renderer,
|
||||
});
|
||||
}
|
||||
}
|
101
ts/components/IncomingCallBar.stories.tsx
Normal file
101
ts/components/IncomingCallBar.stories.tsx
Normal file
|
@ -0,0 +1,101 @@
|
|||
import * as React from 'react';
|
||||
import { IncomingCallBar } from './IncomingCallBar';
|
||||
import { ColorType } from '../types/Util';
|
||||
|
||||
// @ts-ignore
|
||||
import { setup as setupI18n } from '../../js/modules/i18n';
|
||||
// @ts-ignore
|
||||
import enMessages from '../../_locales/en/messages.json';
|
||||
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { boolean, select, text } from '@storybook/addon-knobs';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
const i18n = setupI18n('en', enMessages);
|
||||
|
||||
const defaultProps = {
|
||||
acceptCall: action('accept-call'),
|
||||
callDetails: {
|
||||
avatarPath: undefined,
|
||||
callId: 0,
|
||||
contactColor: 'ultramarine' as ColorType,
|
||||
isIncoming: true,
|
||||
isVideoCall: true,
|
||||
name: 'Rick Sanchez',
|
||||
phoneNumber: '3051234567',
|
||||
profileName: 'Rick Sanchez',
|
||||
},
|
||||
declineCall: action('decline-call'),
|
||||
i18n,
|
||||
};
|
||||
|
||||
const colors: Array<ColorType> = [
|
||||
'blue',
|
||||
'blue_grey',
|
||||
'brown',
|
||||
'deep_orange',
|
||||
'green',
|
||||
'grey',
|
||||
'indigo',
|
||||
'light_green',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'teal',
|
||||
'ultramarine',
|
||||
];
|
||||
|
||||
const permutations = [
|
||||
{
|
||||
title: 'Incoming Call Bar (no call details)',
|
||||
props: {},
|
||||
},
|
||||
{
|
||||
title: 'Incoming Call Bar (video)',
|
||||
props: {
|
||||
callDetails: {
|
||||
...defaultProps.callDetails,
|
||||
isVideoCall: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Incoming Call Bar (audio)',
|
||||
props: {
|
||||
callDetails: {
|
||||
...defaultProps.callDetails,
|
||||
isVideoCall: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
storiesOf('Components/IncomingCallBar', module)
|
||||
.add('Knobs Playground', () => {
|
||||
const contactColor = select('contactColor', colors, 'ultramarine');
|
||||
const isVideoCall = boolean('isVideoCall', false);
|
||||
const name = text(
|
||||
'name',
|
||||
'Rick Sanchez Foo Bar Baz Spool Cool Mango Fango Wand Mars Venus Jupiter Spark Mirage Water Loop Branch Zeus Element Sail Bananas Cars Horticulture Turtle Lion Zebra Micro Music Garage Iguana Ohio Retro Joy Entertainment Logo Understanding Diary'
|
||||
);
|
||||
|
||||
return (
|
||||
<IncomingCallBar
|
||||
{...defaultProps}
|
||||
callDetails={{
|
||||
...defaultProps.callDetails,
|
||||
contactColor,
|
||||
isVideoCall,
|
||||
name,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.add('Iterations', () => {
|
||||
return permutations.map(({ props, title }) => (
|
||||
<>
|
||||
<h3>{title}</h3>
|
||||
<IncomingCallBar {...defaultProps} {...props} />
|
||||
</>
|
||||
));
|
||||
});
|
158
ts/components/IncomingCallBar.tsx
Normal file
158
ts/components/IncomingCallBar.tsx
Normal file
|
@ -0,0 +1,158 @@
|
|||
import React from 'react';
|
||||
import Tooltip from 'react-tooltip-lite';
|
||||
import { Avatar } from './Avatar';
|
||||
import { ContactName } from './conversation/ContactName';
|
||||
import { LocalizerType } from '../types/Util';
|
||||
import {
|
||||
AcceptCallType,
|
||||
CallDetailsType,
|
||||
DeclineCallType,
|
||||
} from '../state/ducks/calling';
|
||||
|
||||
export type PropsType = {
|
||||
acceptCall: (_: AcceptCallType) => void;
|
||||
callDetails?: CallDetailsType;
|
||||
declineCall: (_: DeclineCallType) => void;
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type CallButtonProps = {
|
||||
classSuffix: string;
|
||||
tabIndex: number;
|
||||
tooltipContent: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
const CallButton = ({
|
||||
classSuffix,
|
||||
onClick,
|
||||
tabIndex,
|
||||
tooltipContent,
|
||||
}: CallButtonProps): JSX.Element => {
|
||||
return (
|
||||
<button
|
||||
className={`module-incoming-call__icon module-incoming-call__button--${classSuffix}`}
|
||||
onClick={onClick}
|
||||
tabIndex={tabIndex}
|
||||
>
|
||||
<Tooltip
|
||||
arrowSize={6}
|
||||
content={tooltipContent}
|
||||
direction="bottom"
|
||||
distance={16}
|
||||
hoverDelay={0}
|
||||
>
|
||||
<div />
|
||||
</Tooltip>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
export const IncomingCallBar = ({
|
||||
acceptCall,
|
||||
callDetails,
|
||||
declineCall,
|
||||
i18n,
|
||||
}: PropsType): JSX.Element | null => {
|
||||
if (!callDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
avatarPath,
|
||||
callId,
|
||||
contactColor,
|
||||
name,
|
||||
phoneNumber,
|
||||
profileName,
|
||||
} = callDetails;
|
||||
|
||||
return (
|
||||
<div className="module-incoming-call">
|
||||
<div className="module-incoming-call__contact">
|
||||
<div className="module-incoming-call__contact--avatar">
|
||||
<Avatar
|
||||
avatarPath={avatarPath}
|
||||
color={contactColor || 'ultramarine'}
|
||||
noteToSelf={false}
|
||||
conversationType="direct"
|
||||
i18n={i18n}
|
||||
name={name}
|
||||
phoneNumber={phoneNumber}
|
||||
profileName={profileName}
|
||||
size={52}
|
||||
/>
|
||||
</div>
|
||||
<div className="module-incoming-call__contact--name">
|
||||
<div className="module-incoming-call__contact--name-header">
|
||||
<ContactName
|
||||
phoneNumber={phoneNumber}
|
||||
name={name}
|
||||
profileName={profileName}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
dir="auto"
|
||||
className="module-incoming-call__contact--message-text"
|
||||
>
|
||||
{i18n(
|
||||
callDetails.isVideoCall
|
||||
? 'incomingVideoCall'
|
||||
: 'incomingAudioCall'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-incoming-call__actions">
|
||||
{callDetails.isVideoCall ? (
|
||||
<>
|
||||
<CallButton
|
||||
classSuffix="decline"
|
||||
onClick={() => {
|
||||
declineCall({ callId });
|
||||
}}
|
||||
tabIndex={0}
|
||||
tooltipContent={i18n('declineCall')}
|
||||
/>
|
||||
<CallButton
|
||||
classSuffix="accept-video-as-audio"
|
||||
onClick={() => {
|
||||
acceptCall({ callId, asVideoCall: false });
|
||||
}}
|
||||
tabIndex={0}
|
||||
tooltipContent={i18n('acceptCallWithoutVideo')}
|
||||
/>
|
||||
<CallButton
|
||||
classSuffix="accept-video"
|
||||
onClick={() => {
|
||||
acceptCall({ callId, asVideoCall: true });
|
||||
}}
|
||||
tabIndex={0}
|
||||
tooltipContent={i18n('acceptCall')}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CallButton
|
||||
classSuffix="decline"
|
||||
onClick={() => {
|
||||
declineCall({ callId });
|
||||
}}
|
||||
tabIndex={0}
|
||||
tooltipContent={i18n('declineCall')}
|
||||
/>
|
||||
<CallButton
|
||||
classSuffix="accept-audio"
|
||||
onClick={() => {
|
||||
acceptCall({ callId, asVideoCall: false });
|
||||
}}
|
||||
tabIndex={0}
|
||||
tooltipContent={i18n('acceptCall')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -185,6 +185,17 @@ const COMPOSER_SHORTCUTS: Array<ShortcutType> = [
|
|||
},
|
||||
];
|
||||
|
||||
const CALLING_SHORTCUTS: Array<ShortcutType> = [
|
||||
{
|
||||
description: 'Keyboard--toggle-audio',
|
||||
keys: [['shift', 'M']],
|
||||
},
|
||||
{
|
||||
description: 'Keyboard--toggle-video',
|
||||
keys: [['shift', 'V']],
|
||||
},
|
||||
];
|
||||
|
||||
export const ShortcutGuide = (props: Props) => {
|
||||
const focusRef = React.useRef<HTMLDivElement>(null);
|
||||
const { i18n, close, hasInstalledStickers, platform } = props;
|
||||
|
@ -248,6 +259,16 @@ export const ShortcutGuide = (props: Props) => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section">
|
||||
<div className="module-shortcut-guide__section-header">
|
||||
{i18n('Keyboard--calling-header')}
|
||||
</div>
|
||||
<div className="module-shortcut-guide__section-list">
|
||||
{CALLING_SHORTCUTS.map((shortcut, index) =>
|
||||
renderShortcut(shortcut, index, isMacOS, i18n)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
94
ts/components/conversation/CallingNotification.tsx
Normal file
94
ts/components/conversation/CallingNotification.tsx
Normal file
|
@ -0,0 +1,94 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Timestamp } from './Timestamp';
|
||||
import { LocalizerType } from '../../types/Util';
|
||||
import { CallHistoryDetailsType } from '../../services/calling';
|
||||
|
||||
export type PropsData = {
|
||||
// Can be undefined because it comes from JS.
|
||||
callHistoryDetails?: CallHistoryDetailsType;
|
||||
};
|
||||
|
||||
type PropsHousekeeping = {
|
||||
i18n: LocalizerType;
|
||||
};
|
||||
|
||||
type Props = PropsData & PropsHousekeeping;
|
||||
|
||||
function getMessage(
|
||||
callHistoryDetails: CallHistoryDetailsType,
|
||||
i18n: LocalizerType
|
||||
): string {
|
||||
const {
|
||||
wasIncoming,
|
||||
wasVideoCall,
|
||||
wasDeclined,
|
||||
acceptedTime,
|
||||
} = callHistoryDetails;
|
||||
const wasAccepted = Boolean(acceptedTime);
|
||||
|
||||
if (wasIncoming) {
|
||||
if (wasDeclined) {
|
||||
if (wasVideoCall) {
|
||||
return i18n('declinedIncomingVideoCall');
|
||||
} else {
|
||||
return i18n('declinedIncomingAudioCall');
|
||||
}
|
||||
} else if (wasAccepted) {
|
||||
if (wasVideoCall) {
|
||||
return i18n('acceptedIncomingVideoCall');
|
||||
} else {
|
||||
return i18n('acceptedIncomingAudioCall');
|
||||
}
|
||||
} else {
|
||||
if (wasVideoCall) {
|
||||
return i18n('missedIncomingVideoCall');
|
||||
} else {
|
||||
return i18n('missedIncomingAudioCall');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (wasAccepted) {
|
||||
if (wasVideoCall) {
|
||||
return i18n('acceptedOutgoingVideoCall');
|
||||
} else {
|
||||
return i18n('acceptedOutgoingAudioCall');
|
||||
}
|
||||
} else {
|
||||
if (wasVideoCall) {
|
||||
return i18n('missedOrDeclinedOutgoingVideoCall');
|
||||
} else {
|
||||
return i18n('missedOrDeclinedOutgoingAudioCall');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CallingNotification = (props: Props): JSX.Element | null => {
|
||||
const { callHistoryDetails, i18n } = props;
|
||||
if (!callHistoryDetails) {
|
||||
return null;
|
||||
}
|
||||
const { acceptedTime, endedTime, wasVideoCall } = callHistoryDetails;
|
||||
const callType = wasVideoCall ? 'video' : 'audio';
|
||||
return (
|
||||
<div
|
||||
className={`module-message-calling--notification module-message-calling--${callType}`}
|
||||
>
|
||||
<div className={`module-message-calling--${callType}__icon`} />
|
||||
{getMessage(callHistoryDetails, i18n)}
|
||||
<div>
|
||||
<Timestamp
|
||||
i18n={i18n}
|
||||
timestamp={acceptedTime || endedTime}
|
||||
extended={true}
|
||||
direction="outgoing"
|
||||
withImageNoCaption={false}
|
||||
withSticker={false}
|
||||
withTapToViewExpired={false}
|
||||
module="module-message__metadata__date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -34,6 +34,12 @@ const actionProps: PropsActions = {
|
|||
onDeleteMessages: action('onDeleteMessages'),
|
||||
onResetSession: action('onResetSession'),
|
||||
onSearchInConversation: action('onSearchInConversation'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
),
|
||||
onOutgoingVideoCallInConversation: action(
|
||||
'onOutgoingVideoCallInConversation'
|
||||
),
|
||||
|
||||
onShowSafetyNumber: action('onShowSafetyNumber'),
|
||||
onShowAllMedia: action('onShowAllMedia'),
|
||||
|
|
|
@ -42,6 +42,8 @@ export interface PropsActions {
|
|||
onDeleteMessages: () => void;
|
||||
onResetSession: () => void;
|
||||
onSearchInConversation: () => void;
|
||||
onOutgoingAudioCallInConversation: () => void;
|
||||
onOutgoingVideoCallInConversation: () => void;
|
||||
|
||||
onShowSafetyNumber: () => void;
|
||||
onShowAllMedia: () => void;
|
||||
|
@ -220,6 +222,54 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
);
|
||||
}
|
||||
|
||||
public renderOutgoingAudioCallButton() {
|
||||
if (!window.CALLING) {
|
||||
return null;
|
||||
}
|
||||
if (this.props.isGroup || this.props.isMe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { onOutgoingAudioCallInConversation, showBackButton } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onOutgoingAudioCallInConversation}
|
||||
className={classNames(
|
||||
'module-conversation-header__audio-calling-button',
|
||||
showBackButton
|
||||
? null
|
||||
: 'module-conversation-header__audio-calling-button--show'
|
||||
)}
|
||||
disabled={showBackButton}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderOutgoingVideoCallButton() {
|
||||
if (!window.CALLING) {
|
||||
return null;
|
||||
}
|
||||
if (this.props.isGroup || this.props.isMe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { onOutgoingVideoCallInConversation, showBackButton } = this.props;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onOutgoingVideoCallInConversation}
|
||||
className={classNames(
|
||||
'module-conversation-header__video-calling-button',
|
||||
showBackButton
|
||||
? null
|
||||
: 'module-conversation-header__video-calling-button--show'
|
||||
)}
|
||||
disabled={showBackButton}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
public renderMenu(triggerId: string) {
|
||||
const {
|
||||
i18n,
|
||||
|
@ -298,6 +348,8 @@ export class ConversationHeader extends React.Component<Props> {
|
|||
</div>
|
||||
{this.renderExpirationLength()}
|
||||
{this.renderSearchButton()}
|
||||
{this.renderOutgoingVideoCallButton()}
|
||||
{this.renderOutgoingAudioCallButton()}
|
||||
{this.renderMoreButton(triggerId)}
|
||||
{this.renderMenu(triggerId)}
|
||||
</div>
|
||||
|
|
|
@ -74,17 +74,181 @@ storiesOf('Components/Conversation/TimelineItem', module)
|
|||
|
||||
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
|
||||
})
|
||||
// tslint:disable-next-line max-func-body-length
|
||||
.add('Notification', () => {
|
||||
const item = {
|
||||
type: 'timerNotification',
|
||||
data: {
|
||||
type: 'fromOther',
|
||||
phoneNumber: '(202) 555-0000',
|
||||
timespan: '1 hour',
|
||||
const items = [
|
||||
{
|
||||
type: 'timerNotification',
|
||||
data: {
|
||||
type: 'fromOther',
|
||||
phoneNumber: '(202) 555-0000',
|
||||
timespan: '1 hour',
|
||||
},
|
||||
},
|
||||
} as TimelineItemProps['item'];
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// declined incoming audio
|
||||
wasDeclined: true,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: false,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// declined incoming video
|
||||
wasDeclined: true,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// accepted incoming audio
|
||||
acceptedTime: Date.now() - 300,
|
||||
wasDeclined: false,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: false,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// accepted incoming video
|
||||
acceptedTime: Date.now() - 400,
|
||||
wasDeclined: false,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// missed (neither accepted nor declined) incoming audio
|
||||
wasDeclined: false,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: false,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// missed (neither accepted nor declined) incoming video
|
||||
wasDeclined: false,
|
||||
wasIncoming: true,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// accepted outgoing audio
|
||||
acceptedTime: Date.now() - 200,
|
||||
wasDeclined: false,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// accepted outgoing video
|
||||
acceptedTime: Date.now() - 200,
|
||||
wasDeclined: false,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
callHistoryDetails: {
|
||||
data: {
|
||||
// declined outgoing audio
|
||||
wasDeclined: true,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: false,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// declined outgoing video
|
||||
wasDeclined: true,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// missed (neither accepted nor declined) outgoing audio
|
||||
wasDeclined: false,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: false,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'callHistory',
|
||||
data: {
|
||||
callHistoryDetails: {
|
||||
// missed (neither accepted nor declined) outgoing video
|
||||
wasDeclined: false,
|
||||
wasIncoming: false,
|
||||
wasVideoCall: true,
|
||||
endedTime: Date.now(),
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return <TimelineItem {...getDefaultProps()} item={item} i18n={i18n} />;
|
||||
return (
|
||||
<>
|
||||
{items.map(item => (
|
||||
<>
|
||||
<TimelineItem
|
||||
{...getDefaultProps()}
|
||||
item={item as TimelineItemProps['item']}
|
||||
i18n={i18n}
|
||||
/>
|
||||
<hr />
|
||||
</>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})
|
||||
.add('Unknown Type', () => {
|
||||
// @ts-ignore: intentional
|
||||
|
|
|
@ -8,6 +8,10 @@ import {
|
|||
PropsData as MessageProps,
|
||||
} from './Message';
|
||||
|
||||
import {
|
||||
CallingNotification,
|
||||
PropsData as CallingNotificationProps,
|
||||
} from './CallingNotification';
|
||||
import { InlineNotificationWrapper } from './InlineNotificationWrapper';
|
||||
import {
|
||||
PropsActions as UnsupportedMessageActionsType,
|
||||
|
@ -33,6 +37,10 @@ import {
|
|||
} from './GroupNotification';
|
||||
import { ResetSessionNotification } from './ResetSessionNotification';
|
||||
|
||||
type CallHistoryType = {
|
||||
type: 'callHistory';
|
||||
data: CallingNotificationProps;
|
||||
};
|
||||
type LinkNotificationType = {
|
||||
type: 'linkNotification';
|
||||
data: null;
|
||||
|
@ -66,6 +74,7 @@ type ResetSessionNotificationType = {
|
|||
data: null;
|
||||
};
|
||||
export type TimelineItemType =
|
||||
| CallHistoryType
|
||||
| LinkNotificationType
|
||||
| MessageType
|
||||
| ResetSessionNotificationType
|
||||
|
@ -121,6 +130,8 @@ export class TimelineItem extends React.PureComponent<PropsType> {
|
|||
notification = (
|
||||
<UnsupportedMessage {...this.props} {...item.data} i18n={i18n} />
|
||||
);
|
||||
} else if (item.type === 'callHistory') {
|
||||
notification = <CallingNotification i18n={i18n} {...item.data} />;
|
||||
} else if (item.type === 'linkNotification') {
|
||||
notification = (
|
||||
<div className="module-message-unsynced">
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue