Calling support

This commit is contained in:
Peter Thatcher 2020-06-04 11:16:19 -07:00 committed by Scott Nonnenberg
parent 83574eb067
commit d3a27a6442
72 changed files with 3864 additions and 191 deletions

View 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} />
</>
));
});

View 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;
};

View 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} />
</>
));
});

View 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,
});
}
}

View 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} />
</>
));
});

View 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>
);
};

View file

@ -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>

View 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>
);
};

View file

@ -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'),

View file

@ -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>

View file

@ -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

View file

@ -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">

34
ts/services/bounce.ts Normal file
View file

@ -0,0 +1,34 @@
import { app, BrowserWindow, ipcMain } from 'electron';
let bounceId = -1;
export function init(win: BrowserWindow) {
ipcMain.on('bounce-app-icon-start', (_, isCritical = false) => {
if (app.dock) {
const type = isCritical ? 'critical' : 'informational';
bounceId = app.dock.bounce(type);
if (bounceId < 0) {
return;
}
} else if (win && win.flashFrame) {
win.once('focus', () => {
win.flashFrame(false);
});
win.flashFrame(true);
}
});
ipcMain.on('bounce-app-icon-stop', () => {
if (app.dock) {
if (bounceId < 0) {
return;
}
app.dock.cancelBounce(bounceId);
bounceId = -1;
} else if (win && win.flashFrame) {
win.flashFrame(false);
}
});
}

464
ts/services/calling.ts Normal file
View file

@ -0,0 +1,464 @@
import {
Call,
CallEndedReason,
CallId,
CallLogLevel,
CallSettings,
CallState,
DeviceId,
RingRTC,
UserId,
VideoCapturer,
VideoRenderer,
} from 'ringrtc';
import {
ActionsType as UxActionsType,
CallDetailsType,
} from '../state/ducks/calling';
import { CallingMessageClass, EnvelopeClass } from '../textsecure.d';
import { ConversationType } from '../window.d';
import is from '@sindresorhus/is';
export {
CallState,
CanvasVideoRenderer,
GumVideoCapturer,
VideoCapturer,
VideoRenderer,
} from 'ringrtc';
export type CallHistoryDetailsType = {
wasIncoming: boolean;
wasVideoCall: boolean;
wasDeclined: boolean;
acceptedTime?: number;
endedTime: number;
};
export class CallingClass {
private uxActions?: UxActionsType;
initialize(uxActions: UxActionsType): void {
this.uxActions = uxActions;
if (!uxActions) {
throw new Error('CallingClass.initialize: Invalid uxActions.');
}
if (!is.function_(uxActions.incomingCall)) {
throw new Error(
'CallingClass.initialize: Invalid uxActions.incomingCall'
);
}
if (!is.function_(uxActions.outgoingCall)) {
throw new Error(
'CallingClass.initialize: Invalid uxActions.outgoingCall'
);
}
if (!is.function_(uxActions.callStateChange)) {
throw new Error(
'CallingClass.initialize: Invalid uxActions.callStateChange'
);
}
if (!is.function_(uxActions.remoteVideoChange)) {
throw new Error(
'CallingClass.initialize: Invalid uxActions.remoteVideoChange'
);
}
RingRTC.handleOutgoingSignaling = this.handleOutgoingSignaling.bind(this);
RingRTC.handleIncomingCall = this.handleIncomingCall.bind(this);
RingRTC.handleAutoEndedIncomingCallRequest = this.handleAutoEndedIncomingCallRequest.bind(
this
);
RingRTC.handleLogMessage = this.handleLogMessage.bind(this);
}
async startOutgoingCall(
conversation: ConversationType,
isVideoCall: boolean
) {
if (!this.uxActions) {
window.log.error('Missing uxActions, new call not allowed.');
return;
}
if (RingRTC.call && RingRTC.call.state !== CallState.Ended) {
window.log.info('Call already in progress, new call not allowed.');
return;
}
const remoteUserId = this.getRemoteUserIdFromConversation(conversation);
if (!remoteUserId || !this.localDeviceId) {
window.log.error('Missing identifier, new call not allowed.');
return;
}
const haveMediaPermissions = await this.requestPermissions(isVideoCall);
if (!haveMediaPermissions) {
window.log.info('Permissions were denied, new call not allowed.');
return;
}
// We could make this faster by getting the call object
// from the RingRTC before we lookup the ICE servers.
const call = RingRTC.startOutgoingCall(
remoteUserId,
isVideoCall,
this.localDeviceId,
await this.getCallSettings(conversation)
);
this.attachToCall(conversation, call);
this.uxActions.outgoingCall({
callDetails: this.getUxCallDetails(conversation, call),
});
}
async accept(callId: CallId, asVideoCall: boolean) {
const haveMediaPermissions = await this.requestPermissions(asVideoCall);
if (haveMediaPermissions) {
RingRTC.accept(callId, asVideoCall);
} else {
window.log.info('Permissions were denied, call not allowed, hanging up.');
RingRTC.hangup(callId);
}
}
decline(callId: CallId) {
RingRTC.decline(callId);
}
hangup(callId: CallId) {
RingRTC.hangup(callId);
}
setOutgoingAudio(callId: CallId, enabled: boolean) {
RingRTC.setOutgoingAudio(callId, enabled);
}
setOutgoingVideo(callId: CallId, enabled: boolean) {
RingRTC.setOutgoingVideo(callId, enabled);
}
setVideoCapturer(callId: CallId, capturer: VideoCapturer | null) {
RingRTC.setVideoCapturer(callId, capturer);
}
setVideoRenderer(callId: CallId, renderer: VideoRenderer | null) {
RingRTC.setVideoRenderer(callId, renderer);
}
async handleCallingMessage(
envelope: EnvelopeClass,
callingMessage: CallingMessageClass
) {
const enableIncomingCalls = await window.getIncomingCallNotification();
if (callingMessage.offer && !enableIncomingCalls) {
// Drop offers silently if incoming call notifications are disabled.
window.log.info('Incoming calls are disabled, ignoring call offer.');
return;
}
const remoteUserId = envelope.source || envelope.sourceUuid;
const remoteDeviceId = this.parseDeviceId(envelope.sourceDevice);
if (!remoteUserId || !remoteDeviceId || !this.localDeviceId) {
window.log.error('Missing identifier, ignoring call message.');
return;
}
const now = new Date();
const serverTimestamp = envelope.serverTimestamp
? envelope.serverTimestamp
: now.valueOf();
const messageAgeSec = Math.floor((now.valueOf() - serverTimestamp) / 1000);
RingRTC.handleCallingMessage(
remoteUserId,
remoteDeviceId,
this.localDeviceId,
messageAgeSec,
callingMessage
);
}
private async requestCameraPermissions(): Promise<boolean> {
const cameraPermission = await window.getMediaCameraPermissions();
if (!cameraPermission) {
await window.showCallingPermissionsPopup(true);
// Check the setting again (from the source of truth).
return window.getMediaCameraPermissions();
}
return true;
}
private async requestMicrophonePermissions(): Promise<boolean> {
const microphonePermission = await window.getMediaPermissions();
if (!microphonePermission) {
await window.showCallingPermissionsPopup(false);
// Check the setting again (from the source of truth).
return window.getMediaPermissions();
}
return true;
}
private async requestPermissions(isVideoCall: boolean): Promise<boolean> {
const microphonePermission = await this.requestMicrophonePermissions();
if (microphonePermission) {
if (isVideoCall) {
return this.requestCameraPermissions();
} else {
return true;
}
} else {
return false;
}
}
private async handleOutgoingSignaling(
remoteUserId: UserId,
message: CallingMessageClass
): Promise<void> {
const conversation = window.ConversationController.get(remoteUserId);
const sendOptions = conversation ? conversation.getSendOptions() : {};
try {
await window.textsecure.messaging.sendCallingMessage(
remoteUserId,
message,
sendOptions
);
window.log.info('handleOutgoingSignaling() completed successfully');
} catch (err) {
if (err && err.errors && err.errors.length > 0) {
window.log.error(
`handleOutgoingSignaling() failed: ${err.errors[0].reason}`
);
} else {
window.log.error('handleOutgoingSignaling() failed');
}
}
}
private async handleIncomingCall(call: Call): Promise<CallSettings | null> {
if (!this.uxActions || !this.localDeviceId) {
window.log.error('Missing required objects, ignoring incoming call.');
return null;
}
const conversation = window.ConversationController.get(call.remoteUserId);
if (!conversation) {
window.log.error('Missing conversation, ignoring incoming call.');
return null;
}
try {
// The peer must be 'trusted' before accepting a call from them.
// This is mostly the safety number check, unverified meaning that they were
// verified before but now they are not.
const verifiedEnum = await conversation.safeGetVerified();
if (
verifiedEnum ===
window.textsecure.storage.protocol.VerifiedStatus.UNVERIFIED
) {
window.log.info('Peer is not trusted, ignoring incoming call.');
this.addCallHistoryForFailedIncomingCall(conversation, call);
return null;
}
this.attachToCall(conversation, call);
this.uxActions.incomingCall({
callDetails: this.getUxCallDetails(conversation, call),
});
return await this.getCallSettings(conversation);
} catch (err) {
window.log.error(`Ignoring incoming call: ${err.stack}`);
this.addCallHistoryForFailedIncomingCall(conversation, call);
return null;
}
}
private handleAutoEndedIncomingCallRequest(
remoteUserId: UserId,
reason: CallEndedReason
) {
const conversation = window.ConversationController.get(remoteUserId);
if (!conversation) {
return;
}
this.addCallHistoryForAutoEndedIncomingCall(conversation, reason);
}
private attachToCall(conversation: ConversationType, call: Call): void {
const { uxActions } = this;
if (!uxActions) {
return;
}
let acceptedTime: number | undefined;
call.handleStateChanged = () => {
if (call.state === CallState.Accepted) {
acceptedTime = Date.now();
} else if (call.state === CallState.Ended) {
this.addCallHistoryForEndedCall(conversation, call, acceptedTime);
}
uxActions.callStateChange({
callState: call.state,
callDetails: this.getUxCallDetails(conversation, call),
});
};
call.handleRemoteVideoEnabled = () => {
uxActions.remoteVideoChange({
remoteVideoEnabled: call.remoteVideoEnabled,
});
};
}
private async handleLogMessage(
level: CallLogLevel,
fileName: string,
line: number,
message: string
) {
// info/warn/error are only needed to be logged for now.
// tslint:disable-next-line switch-default
switch (level) {
case CallLogLevel.Info:
window.log.info(`${fileName}:${line} ${message}`);
break;
case CallLogLevel.Warn:
window.log.warn(`${fileName}:${line} ${message}`);
break;
case CallLogLevel.Error:
window.log.error(`${fileName}:${line} ${message}`);
}
}
private getRemoteUserIdFromConversation(
conversation: ConversationType
): UserId | undefined {
const recipients = conversation.getRecipients();
if (recipients.length !== 1) {
return undefined;
}
return recipients[0];
}
private get localDeviceId(): DeviceId | null {
return this.parseDeviceId(window.textsecure.storage.user.getDeviceId());
}
private parseDeviceId(
deviceId: number | string | undefined
): DeviceId | null {
if (typeof deviceId === 'string') {
return parseInt(deviceId, 10);
}
if (typeof deviceId === 'number') {
return deviceId;
}
return null;
}
private async getCallSettings(
conversation: ConversationType
): Promise<CallSettings> {
const iceServerJson = await window.textsecure.messaging.server.getIceServers();
const shouldRelayCalls = Boolean(await window.getAlwaysRelayCalls());
// If the peer is 'unknown', i.e. not in the contact list, force IP hiding.
const isContactUnknown = !conversation.getIsAddedByContact();
return {
iceServer: JSON.parse(iceServerJson),
hideIp: shouldRelayCalls || isContactUnknown,
};
}
private getUxCallDetails(
conversation: ConversationType,
call: Call
): CallDetailsType {
return {
avatarPath: conversation.getAvatarPath(),
callId: call.callId,
contactColor: conversation.getColor(),
isIncoming: call.isIncoming,
isVideoCall: call.isVideoCall,
name: conversation.getName(),
phoneNumber: conversation.getNumber(),
profileName: conversation.getProfileName(),
};
}
private addCallHistoryForEndedCall(
conversation: ConversationType,
call: Call,
acceptedTime: number | undefined
) {
const { endedReason, isIncoming } = call;
const wasAccepted = Boolean(acceptedTime);
const isOutgoing = !isIncoming;
const wasDeclined =
!wasAccepted &&
(endedReason === CallEndedReason.Declined ||
endedReason === CallEndedReason.DeclinedOnAnotherDevice ||
(isIncoming && endedReason === CallEndedReason.LocalHangup) ||
(isOutgoing && endedReason === CallEndedReason.RemoteHangup));
if (call.endedReason === CallEndedReason.AcceptedOnAnotherDevice) {
// tslint:disable-next-line no-parameter-reassignment
acceptedTime = Date.now();
}
const callHistoryDetails: CallHistoryDetailsType = {
wasIncoming: call.isIncoming,
wasVideoCall: call.isVideoCall,
wasDeclined,
acceptedTime,
endedTime: Date.now(),
};
conversation.addCallHistory(callHistoryDetails);
}
private addCallHistoryForFailedIncomingCall(
conversation: ConversationType,
call: Call
) {
const callHistoryDetails: CallHistoryDetailsType = {
wasIncoming: true,
wasVideoCall: call.isVideoCall,
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: Date.now(),
};
conversation.addCallHistory(callHistoryDetails);
}
private addCallHistoryForAutoEndedIncomingCall(
conversation: ConversationType,
_reason: CallEndedReason
) {
const callHistoryDetails: CallHistoryDetailsType = {
wasIncoming: true,
// We don't actually know, but it doesn't seem that important in this case,
// but we could maybe plumb this info through RingRTC
wasVideoCall: false,
// Since the user didn't decline, make sure it shows up as a missed call instead
wasDeclined: false,
acceptedTime: undefined,
endedTime: Date.now(),
};
conversation.addCallHistory(callHistoryDetails);
}
}
export const calling = new CallingClass();

34
ts/services/notify.ts Normal file
View file

@ -0,0 +1,34 @@
function filter(text: string) {
return (text || '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
type NotificationType = {
platform: string;
icon: string;
message: string;
onNotificationClick: () => void;
silent: boolean;
title: string;
};
export function notify({
platform,
icon,
message,
onNotificationClick,
silent,
title,
}: NotificationType): Notification {
const notification = new window.Notification(title, {
body: platform === 'linux' ? filter(message) : message,
icon,
silent,
});
notification.onclick = onNotificationClick;
return notification;
}

View file

@ -0,0 +1,9 @@
import { ipcRenderer } from 'electron';
export function bounceAppIconStart(isCritical = false) {
ipcRenderer.send('bounce-app-icon-start', isCritical);
}
export function bounceAppIconStop() {
ipcRenderer.send('bounce-app-icon-stop');
}

View file

@ -1,3 +1,4 @@
import { actions as calling } from './ducks/calling';
import { actions as conversations } from './ducks/conversations';
import { actions as emojis } from './ducks/emojis';
import { actions as expiration } from './ducks/expiration';
@ -9,6 +10,7 @@ import { actions as updates } from './ducks/updates';
import { actions as user } from './ducks/user';
export const mapDispatchToProps = {
...calling,
...conversations,
...emojis,
...expiration,

417
ts/state/ducks/calling.ts Normal file
View file

@ -0,0 +1,417 @@
import { notify } from '../../services/notify';
import { calling, VideoCapturer, VideoRenderer } from '../../services/calling';
import { CallState } from '../../types/Calling';
import { CanvasVideoRenderer, GumVideoCapturer } from '../../window.d';
import { ColorType } from '../../types/Util';
import { NoopActionType } from './noop';
import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions';
import {
bounceAppIconStart,
bounceAppIconStop,
} from '../../shims/bounceAppIcon';
// State
export type CallId = any;
export type CallDetailsType = {
avatarPath?: string;
callId: CallId;
contactColor?: ColorType;
isIncoming: boolean;
isVideoCall: boolean;
name?: string;
phoneNumber: string;
profileName?: string;
};
export type CallingStateType = {
callDetails?: CallDetailsType;
callState?: CallState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
};
export type AcceptCallType = {
callId: CallId;
asVideoCall: boolean;
};
export type CallStateChangeType = {
callState: CallState;
callDetails: CallDetailsType;
};
export type DeclineCallType = {
callId: CallId;
};
export type HangUpType = {
callId: CallId;
};
export type IncomingCallType = {
callDetails: CallDetailsType;
};
export type OutgoingCallType = {
callDetails: CallDetailsType;
};
export type RemoteVideoChangeType = {
remoteVideoEnabled: boolean;
};
export type SetLocalAudioType = {
callId: CallId;
enabled: boolean;
};
export type SetLocalVideoType = {
callId: CallId;
enabled: boolean;
};
export type SetVideoCapturerType = {
callId: CallId;
capturer: CanvasVideoRenderer | null;
};
export type SetVideoRendererType = {
callId: CallId;
renderer: GumVideoCapturer | null;
};
// Actions
const ACCEPT_CALL = 'calling/ACCEPT_CALL';
const CALL_STATE_CHANGE = 'calling/CALL_STATE_CHANGE';
const DECLINE_CALL = 'calling/DECLINE_CALL';
const HANG_UP = 'calling/HANG_UP';
const INCOMING_CALL = 'calling/INCOMING_CALL';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO = 'calling/SET_LOCAL_AUDIO';
const SET_LOCAL_VIDEO = 'calling/SET_LOCAL_VIDEO';
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
type AcceptCallActionType = {
type: 'calling/ACCEPT_CALL';
payload: AcceptCallType;
};
type CallStateChangeActionType = {
type: 'calling/CALL_STATE_CHANGE';
payload: CallStateChangeType;
};
type DeclineCallActionType = {
type: 'calling/DECLINE_CALL';
payload: DeclineCallType;
};
type HangUpActionType = {
type: 'calling/HANG_UP';
payload: HangUpType;
};
type IncomingCallActionType = {
type: 'calling/INCOMING_CALL';
payload: IncomingCallType;
};
type OutgoingCallActionType = {
type: 'calling/OUTGOING_CALL';
payload: OutgoingCallType;
};
type RemoteVideoChangeActionType = {
type: 'calling/REMOTE_VIDEO_CHANGE';
payload: RemoteVideoChangeType;
};
type SetLocalAudioActionType = {
type: 'calling/SET_LOCAL_AUDIO';
payload: SetLocalAudioType;
};
type SetLocalVideoActionType = {
type: 'calling/SET_LOCAL_VIDEO';
payload: Promise<SetLocalVideoType>;
};
type SetLocalVideoFulfilledActionType = {
type: 'calling/SET_LOCAL_VIDEO_FULFILLED';
payload: SetLocalVideoType;
};
export type CallingActionType =
| AcceptCallActionType
| CallStateChangeActionType
| DeclineCallActionType
| HangUpActionType
| IncomingCallActionType
| OutgoingCallActionType
| RemoteVideoChangeActionType
| SetLocalAudioActionType
| SetLocalVideoActionType
| SetLocalVideoFulfilledActionType;
// Action Creators
function acceptCall(
payload: AcceptCallType
): AcceptCallActionType | NoopActionType {
// tslint:disable-next-line no-floating-promises
(async () => {
try {
await calling.accept(payload.callId, payload.asVideoCall);
} catch (err) {
window.log.error(`Failed to acceptCall: ${err.stack}`);
}
})();
return {
type: ACCEPT_CALL,
payload,
};
}
function callStateChange(
payload: CallStateChangeType
): CallStateChangeActionType {
const { callDetails, callState } = payload;
const { isIncoming } = callDetails;
if (callState === CallState.Ringing && isIncoming) {
// tslint:disable-next-line no-floating-promises
callingTones.playRingtone();
// tslint:disable-next-line no-floating-promises
showCallNotification(callDetails);
bounceAppIconStart();
}
if (callState !== CallState.Ringing) {
callingTones.stopRingtone();
bounceAppIconStop();
}
if (callState === CallState.Ended) {
// tslint:disable-next-line no-floating-promises
callingTones.playEndCall();
}
return {
type: CALL_STATE_CHANGE,
payload,
};
}
async function showCallNotification(callDetails: CallDetailsType) {
const canNotify = await window.getCallSystemNotification();
if (!canNotify) {
return;
}
const { name, phoneNumber, profileName, isVideoCall } = callDetails;
notify({
platform: window.platform,
title: `${name || phoneNumber} ${profileName || ''}`,
icon: isVideoCall
? 'images/icons/v2/video-solid-24.svg'
: 'images/icons/v2/phone-right-solid-24.svg',
message: window.i18n(
isVideoCall ? 'incomingVideoCall' : 'incomingAudioCall'
),
onNotificationClick: () => {
window.showWindow();
},
silent: false,
});
}
function declineCall(payload: DeclineCallType): DeclineCallActionType {
calling.decline(payload.callId);
return {
type: DECLINE_CALL,
payload,
};
}
function hangUp(payload: HangUpType): HangUpActionType {
calling.hangup(payload.callId);
return {
type: HANG_UP,
payload,
};
}
function incomingCall(payload: IncomingCallType): IncomingCallActionType {
return {
type: INCOMING_CALL,
payload,
};
}
function outgoingCall(payload: OutgoingCallType): OutgoingCallActionType {
// tslint:disable-next-line no-floating-promises
callingTones.playRingtone();
return {
type: OUTGOING_CALL,
payload,
};
}
function remoteVideoChange(
payload: RemoteVideoChangeType
): RemoteVideoChangeActionType {
return {
type: REMOTE_VIDEO_CHANGE,
payload,
};
}
function setVideoCapturer(payload: SetVideoCapturerType): NoopActionType {
calling.setVideoCapturer(payload.callId, payload.capturer as VideoCapturer);
return {
type: 'NOOP',
payload: null,
};
}
function setVideoRenderer(payload: SetVideoRendererType): NoopActionType {
calling.setVideoRenderer(payload.callId, payload.renderer as VideoRenderer);
return {
type: 'NOOP',
payload: null,
};
}
function setLocalAudio(payload: SetLocalAudioType): SetLocalAudioActionType {
calling.setOutgoingAudio(payload.callId, payload.enabled);
return {
type: SET_LOCAL_AUDIO,
payload,
};
}
function setLocalVideo(payload: SetLocalVideoType): SetLocalVideoActionType {
return {
type: SET_LOCAL_VIDEO,
payload: doSetLocalVideo(payload),
};
}
async function doSetLocalVideo(
payload: SetLocalVideoType
): Promise<SetLocalVideoType> {
if (await requestCameraPermissions()) {
calling.setOutgoingVideo(payload.callId, payload.enabled);
return payload;
}
return {
...payload,
enabled: false,
};
}
export const actions = {
acceptCall,
callStateChange,
declineCall,
hangUp,
incomingCall,
outgoingCall,
remoteVideoChange,
setVideoCapturer,
setVideoRenderer,
setLocalAudio,
setLocalVideo,
};
export type ActionsType = typeof actions;
// Reducer
function getEmptyState(): CallingStateType {
return {
callDetails: undefined,
callState: undefined,
hasLocalAudio: false,
hasLocalVideo: false,
hasRemoteVideo: false,
};
}
export function reducer(
state: CallingStateType = getEmptyState(),
action: CallingActionType
): CallingStateType {
if (action.type === ACCEPT_CALL) {
return {
...state,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
};
}
if (action.type === DECLINE_CALL || action.type === HANG_UP) {
return getEmptyState();
}
if (action.type === INCOMING_CALL) {
return {
...state,
callDetails: action.payload.callDetails,
callState: CallState.Prering,
};
}
if (action.type === OUTGOING_CALL) {
return {
...state,
callDetails: action.payload.callDetails,
callState: CallState.Prering,
hasLocalAudio: true,
hasLocalVideo: action.payload.callDetails.isVideoCall,
};
}
if (action.type === CALL_STATE_CHANGE) {
if (action.payload.callState === CallState.Ended) {
return getEmptyState();
}
return {
...state,
callState: action.payload.callState,
};
}
if (action.type === REMOTE_VIDEO_CHANGE) {
return {
...state,
hasRemoteVideo: action.payload.remoteVideoEnabled,
};
}
if (action.type === SET_LOCAL_AUDIO) {
return {
...state,
hasLocalAudio: action.payload.enabled,
};
}
if (action.type === SET_LOCAL_VIDEO_FULFILLED) {
return {
...state,
hasLocalVideo: action.payload.enabled,
};
}
return state;
}

View file

@ -72,7 +72,8 @@ export type MessageType = {
| 'group'
| 'keychange'
| 'verified-change'
| 'message-history-unsynced';
| 'message-history-unsynced'
| 'call-history';
quote?: { author: string };
received_at: number;
hasSignalAccount?: boolean;

View file

@ -1,5 +1,10 @@
import { combineReducers } from 'redux';
import {
CallingActionType,
CallingStateType,
reducer as calling,
} from './ducks/calling';
import {
ConversationActionType,
ConversationsStateType,
@ -43,6 +48,7 @@ import {
import { reducer as user, UserStateType } from './ducks/user';
export type StateType = {
calling: CallingStateType;
conversations: ConversationsStateType;
emojis: EmojisStateType;
expiration: ExpirationStateType;
@ -55,6 +61,7 @@ export type StateType = {
};
export type ActionsType =
| CallingActionType
| EmojisActionType
| ExpirationActionType
| ConversationActionType
@ -65,6 +72,7 @@ export type ActionsType =
| UpdatesActionType;
export const reducers = {
calling,
conversations,
emojis,
expiration,

View file

@ -0,0 +1,16 @@
import React from 'react';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import { SmartCallManager } from '../smart/CallManager';
// Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
const FilteredCallManager = SmartCallManager as any;
export const createCallManager = (store: Store) => (
<Provider store={store}>
<FilteredCallManager />
</Provider>
);

View file

@ -0,0 +1,23 @@
import { RefObject } from 'react';
import { connect } from 'react-redux';
import { CanvasVideoRenderer, GumVideoCapturer } from 'ringrtc';
import { mapDispatchToProps } from '../actions';
import { CallManager } from '../../components/CallManager';
import { StateType } from '../reducer';
import { getIntl } from '../selectors/user';
const mapStateToProps = (state: StateType) => {
return {
...state.calling,
i18n: getIntl(state),
getVideoCapturer: (localVideoRef: RefObject<HTMLVideoElement>) =>
new GumVideoCapturer(640, 480, 30, localVideoRef),
getVideoRenderer: (remoteVideoRef: RefObject<HTMLCanvasElement>) =>
new CanvasVideoRenderer(remoteVideoRef),
};
};
const smart = connect(mapStateToProps, mapDispatchToProps);
export const SmartCallManager = smart(CallManager);

69
ts/textsecure.d.ts vendored
View file

@ -8,6 +8,8 @@ import Crypto from './textsecure/Crypto';
import MessageReceiver from './textsecure/MessageReceiver';
import EventTarget from './textsecure/EventTarget';
import { ByteBufferClass } from './window.d';
import { SendOptionsType } from './textsecure/SendMessage';
import { WebAPIType } from './textsecure/WebAPI';
type AttachmentType = any;
@ -76,6 +78,12 @@ export type TextSecureType = {
}>,
options: Object
) => Promise<void>;
sendCallingMessage: (
recipientId: string,
callingMessage: CallingMessageClass,
sendOptions: SendOptionsType
) => Promise<void>;
server: WebAPIType;
};
protobuf: ProtobufCollectionType;
@ -229,7 +237,7 @@ export declare class ContentClass {
dataMessage?: DataMessageClass;
syncMessage?: SyncMessageClass;
callMessage?: any;
callingMessage?: CallingMessageClass;
nullMessage?: NullMessageClass;
receiptMessage?: ReceiptMessageClass;
typingMessage?: TypingMessageClass;
@ -708,3 +716,62 @@ export declare class WebSocketResponseMessageClass {
headers?: Array<string>;
body?: ProtoBinaryType;
}
// Everything from here down to HangupType (everything related to calling)
// must be kept in sync with RingRTC (ringrtc-node).
// Whenever you change this, make sure you change RingRTC as well.
export type DeviceId = number;
export type CallId = any;
export class CallingMessageClass {
offer?: OfferMessageClass;
answer?: AnswerMessageClass;
iceCandidates?: Array<IceCandidateMessageClass>;
legacyHangup?: HangupMessageClass;
busy?: BusyMessageClass;
hangup?: HangupMessageClass;
supportsMultiRing?: boolean;
destinationDeviceId?: DeviceId;
}
export class OfferMessageClass {
callId?: CallId;
type?: OfferType;
sdp?: string;
}
export enum OfferType {
AudioCall = 0,
VideoCall = 1,
NeedsPermission = 2,
}
export class AnswerMessageClass {
callId?: CallId;
sdp?: string;
}
export class IceCandidateMessageClass {
callId?: CallId;
mid?: string;
midIndex?: number;
sdp?: string;
}
export class BusyMessageClass {
callId?: CallId;
}
export class HangupMessageClass {
callId?: CallId;
type?: HangupType;
deviceId?: DeviceId;
}
export enum HangupType {
Normal = 0,
Accepted = 1,
Declined = 2,
Busy = 3,
}

View file

@ -19,6 +19,7 @@ import { IncomingIdentityKeyError } from './Errors';
import {
AttachmentPointerClass,
CallingMessageClass,
DataMessageClass,
DownloadAttachmentType,
EnvelopeClass,
@ -1217,9 +1218,8 @@ class MessageReceiverInner extends EventTarget {
} else if (content.nullMessage) {
this.handleNullMessage(envelope);
return;
} else if (content.callMessage) {
this.handleCallMessage(envelope);
return;
} else if (content.callingMessage) {
return this.handleCallingMessage(envelope, content.callingMessage);
} else if (content.receiptMessage) {
return this.handleReceiptMessage(envelope, content.receiptMessage);
} else if (content.typingMessage) {
@ -1228,9 +1228,15 @@ class MessageReceiverInner extends EventTarget {
this.removeFromCache(envelope);
throw new Error('Unsupported content message');
}
handleCallMessage(envelope: EnvelopeClass) {
window.log.info('call message from', this.getEnvelopeId(envelope));
async handleCallingMessage(
envelope: EnvelopeClass,
callingMessage: CallingMessageClass
) {
this.removeFromCache(envelope);
await window.Signal.Services.calling.handleCallingMessage(
envelope,
callingMessage
);
}
async handleReceiptMessage(
envelope: EnvelopeClass,

View file

@ -9,6 +9,7 @@ import OutgoingMessage from './OutgoingMessage';
import Crypto from './Crypto';
import {
AttachmentPointerClass,
CallingMessageClass,
ContentClass,
DataMessageClass,
} from '../textsecure.d';
@ -892,6 +893,28 @@ export default class MessageSender {
);
}
async sendCallingMessage(
recipientId: string,
callingMessage: CallingMessageClass,
sendOptions: SendOptionsType
) {
const recipients = [recipientId];
const finalTimestamp = Date.now();
const contentMessage = new window.textsecure.protobuf.Content();
contentMessage.callingMessage = callingMessage;
const silent = true;
await this.sendMessageProtoAndWait(
finalTimestamp,
recipients,
contentMessage,
silent,
sendOptions
);
}
async sendDeliveryReceipt(
recipientE164: string,
recipientUuid: string,

View file

@ -478,6 +478,7 @@ const URL_CALLS = {
accounts: 'v1/accounts',
updateDeviceName: 'v1/accounts/name',
removeSignalingKey: 'v1/accounts/signaling_key',
getIceServers: 'v1/accounts/turn',
attachmentId: 'v2/attachments/form/upload',
deliveryCert: 'v1/certificate/delivery',
supportUnauthenticatedDelivery: 'v1/devices/unauthenticated_delivery',
@ -541,6 +542,7 @@ export type WebAPIType = {
getAttachment: (cdnKey: string, cdnNumber: number) => Promise<any>;
getAvatar: (path: string) => Promise<any>;
getDevices: () => Promise<any>;
getIceServers: () => Promise<any>;
getKeysForIdentifier: (
identifier: string,
deviceId?: number
@ -702,6 +704,7 @@ export function initialize({
getAttachment,
getAvatar,
getDevices,
getIceServers,
getKeysForIdentifier,
getKeysForIdentifierUnauth,
getMessageSocket,
@ -983,6 +986,13 @@ export function initialize({
});
}
async function getIceServers() {
return _ajax({
call: 'getIceServers',
httpType: 'GET',
});
}
async function removeSignalingKey() {
return _ajax({
call: 'removeSignalingKey',

8
ts/types/Calling.ts Normal file
View file

@ -0,0 +1,8 @@
// This must be kept in sync with RingRTC.CallState.
export enum CallState {
Prering = 'init',
Ringing = 'ringing',
Accepted = 'connected',
Reconnecting = 'connecting',
Ended = 'ended',
}

75
ts/util/Sound.ts Normal file
View file

@ -0,0 +1,75 @@
export type SoundOpts = {
loop?: boolean;
src: string;
};
export class Sound {
static sounds = new Map();
private readonly context = new AudioContext();
private readonly loop: boolean;
private node?: AudioBufferSourceNode;
private readonly src: string;
constructor(options: SoundOpts) {
this.loop = Boolean(options.loop);
this.src = options.src;
}
async play() {
if (!Sound.sounds.has(this.src)) {
try {
const buffer = await Sound.loadSoundFile(this.src);
const decodedBuffer = await this.context.decodeAudioData(buffer);
Sound.sounds.set(this.src, decodedBuffer);
} catch (err) {
window.log.error(`Sound error: ${err}`);
return;
}
}
const soundBuffer = Sound.sounds.get(this.src);
const soundNode = this.context.createBufferSource();
soundNode.buffer = soundBuffer;
const volumeNode = this.context.createGain();
soundNode.connect(volumeNode);
volumeNode.connect(this.context.destination);
soundNode.loop = this.loop;
soundNode.start(0, 0);
this.node = soundNode;
}
stop() {
if (this.node) {
this.node.stop(0);
this.node = undefined;
}
}
static async loadSoundFile(src: string): Promise<ArrayBuffer> {
const xhr = new XMLHttpRequest();
xhr.open('GET', src, true);
xhr.responseType = 'arraybuffer';
return new Promise((resolve, reject) => {
xhr.onload = () => {
if (xhr.status === 200) {
resolve(xhr.response);
return;
}
reject(new Error(`Request failed: ${xhr.statusText}`));
};
xhr.onerror = () => {
reject(new Error(`Request failed, most likely file not found: ${src}`));
};
xhr.send();
});
}
}

View file

@ -0,0 +1,10 @@
export async function requestCameraPermissions(): Promise<boolean> {
if (!(await window.getMediaCameraPermissions())) {
await window.showCallingPermissionsPopup(true);
// Check the setting again (from the source of truth).
return window.getMediaCameraPermissions();
}
return true;
}

44
ts/util/callingTones.ts Normal file
View file

@ -0,0 +1,44 @@
import { Sound, SoundOpts } from './Sound';
async function playSound(howlProps: SoundOpts): Promise<Sound | undefined> {
const canPlayTone = await window.getCallRingtoneNotification();
if (!canPlayTone) {
return;
}
const tone = new Sound(howlProps);
await tone.play();
return tone;
}
class CallingTones {
private ringtone?: Sound;
async playEndCall() {
await playSound({
src: 'sounds/navigation-cancel.ogg',
});
}
async playRingtone() {
if (this.ringtone) {
this.stopRingtone();
}
this.ringtone = await playSound({
loop: true,
src: 'sounds/ringtone_minimal.ogg',
});
}
stopRingtone() {
if (this.ringtone) {
this.ringtone.stop();
this.ringtone = undefined;
}
}
}
export const callingTones = new CallingTones();

View file

@ -289,9 +289,9 @@
"rule": "jQuery-appendTo(",
"path": "js/permissions_popup_start.js",
"line": "window.view.$el.appendTo($body);",
"lineNumber": 42,
"lineNumber": 57,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
@ -316,9 +316,9 @@
"rule": "jQuery-appendTo(",
"path": "js/settings_start.js",
"line": " window.view.$el.appendTo($body);",
"lineNumber": 57,
"lineNumber": 63,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T18:13:29.628Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
@ -513,81 +513,99 @@
"rule": "jQuery-appendTo(",
"path": "js/views/inbox_view.js",
"line": " toast.$el.appendTo(this.$el);",
"lineNumber": 98,
"lineNumber": 99,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 118,
"line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);",
"lineNumber": 121,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "<optional>"
},
{
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " this.$('.call-manager-placeholder').append(this.callManagerView.el);",
"lineNumber": 121,
"reasonCategory": "usageTrusted",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "<optional>"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 132,
"reasonCategory": "usageTrusted",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-append(",
"path": "js/views/inbox_view.js",
"line": " this.$('.left-pane-placeholder').append(this.leftPaneView.el);",
"lineNumber": 118,
"lineNumber": 132,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.placeholder').length) {",
"lineNumber": 168,
"lineNumber": 183,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('#header, .gutter').addClass('inactive');",
"lineNumber": 172,
"lineNumber": 187,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation-stack').addClass('inactive');",
"lineNumber": 176,
"lineNumber": 191,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .menu').trigger('close');",
"lineNumber": 178,
"lineNumber": 193,
"reasonCategory": "usageTrusted",
"updated": "2019-10-21T22:30:15.622Z",
"updated": "2020-05-28T17:42:35.329Z",
"reasonDetail": "Hardcoded selector"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " if (e && this.$(e.target).closest('.capture-audio').length > 0) {",
"lineNumber": 198,
"lineNumber": 213,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"updated": "2020-05-29T18:29:18.234Z",
"reasonDetail": "Known DOM elements"
},
{
"rule": "jQuery-$(",
"path": "js/views/inbox_view.js",
"line": " this.$('.conversation:first .recorder').trigger('close');",
"lineNumber": 201,
"lineNumber": 216,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"updated": "2020-05-29T18:29:18.234Z",
"reasonDetail": "Hardcoded selector"
},
{
@ -838,150 +856,200 @@
"line": " this.$('input').prop('checked', Boolean(this.value));",
"lineNumber": 49,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T22:20:33.618Z"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('input').prop('checked', Boolean(this.value));",
"lineNumber": 68,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " const value = this.$(e.target).val();",
"lineNumber": 64,
"lineNumber": 83,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$(`#${this.name}-${this.value}`).attr('checked', 'checked');",
"lineNumber": 69,
"lineNumber": 88,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.notification-settings'),",
"lineNumber": 78,
"lineNumber": 97,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.theme-settings'),",
"lineNumber": 84,
"lineNumber": 103,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " $(document.body)",
"lineNumber": 88,
"lineNumber": 107,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.audio-notification-setting'),",
"lineNumber": 99,
"lineNumber": 118,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.spell-check-setting'),",
"lineNumber": 106,
"lineNumber": 125,
"reasonCategory": "usageTrusted",
"updated": "2018-09-19T21:59:32.770Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " const $msg = this.$('.spell-check-setting-message');",
"lineNumber": 110,
"lineNumber": 129,
"reasonCategory": "usageTrusted",
"updated": "2020-03-19T16:06:32.598Z"
"updated": "2020-06-02T21:51:34.813Z"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.menu-bar-setting'),",
"lineNumber": 123,
"lineNumber": 142,
"reasonCategory": "usageTrusted",
"updated": "2020-03-20T16:47:14.450Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.media-permissions'),",
"lineNumber": 130,
"line": " el: this.$('.always-relay-calls-setting'),",
"lineNumber": 149,
"reasonCategory": "usageTrusted",
"updated": "2020-03-20T16:47:14.450Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.call-ringtone-notification-setting'),",
"lineNumber": 155,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.call-system-notification-setting'),",
"lineNumber": 161,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.incoming-call-notification-setting'),",
"lineNumber": 167,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.media-permissions'),",
"lineNumber": 173,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " el: this.$('.media-camera-permissions'),",
"lineNumber": 178,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.sync-setting').append(syncView.el);",
"lineNumber": 136,
"lineNumber": 184,
"reasonCategory": "usageTrusted",
"updated": "2020-03-20T16:47:14.450Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-append(",
"path": "js/views/settings_view.js",
"line": " this.$('.sync-setting').append(syncView.el);",
"lineNumber": 136,
"lineNumber": 184,
"reasonCategory": "usageTrusted",
"updated": "2020-03-20T16:47:14.450Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Interacting with already-existing DOM nodes"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.sync').text(i18n('syncNow'));",
"lineNumber": 200,
"lineNumber": 263,
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T13:52:04.149Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.sync').attr('disabled', 'disabled');",
"lineNumber": 204,
"lineNumber": 267,
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T13:52:04.149Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.synced_at').hide();",
"lineNumber": 216,
"lineNumber": 279,
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T13:52:04.149Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
"rule": "jQuery-$(",
"path": "js/views/settings_view.js",
"line": " this.$('.sync_failed').hide();",
"lineNumber": 221,
"lineNumber": 284,
"reasonCategory": "usageTrusted",
"updated": "2020-03-25T13:52:04.149Z",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Protected from arbitrary input"
},
{
@ -11412,6 +11480,24 @@
"updated": "2018-09-17T20:50:40.689Z",
"reasonDetail": "Hard-coded value"
},
{
"rule": "React-createRef",
"path": "ts/components/CallScreen.js",
"line": " this.localVideoRef = react_1.default.createRef();",
"lineNumber": 97,
"reasonCategory": "usageTrusted",
"updated": "2020-05-28T17:22:06.472Z",
"reasonDetail": "Used to render local preview video"
},
{
"rule": "React-createRef",
"path": "ts/components/CallScreen.tsx",
"line": " this.localVideoRef = React.createRef();",
"lineNumber": 80,
"reasonCategory": "usageTrusted",
"updated": "2020-06-02T21:51:34.813Z",
"reasonDetail": "Used to render local preview video"
},
{
"rule": "React-createRef",
"path": "ts/components/CaptionEditor.js",
@ -11515,7 +11601,7 @@
"rule": "React-createRef",
"path": "ts/components/conversation/ConversationHeader.tsx",
"line": " this.menuTriggerRef = React.createRef();",
"lineNumber": 68,
"lineNumber": 70,
"reasonCategory": "usageTrusted",
"updated": "2020-05-20T20:10:43.540Z",
"reasonDetail": "Used to reference popup menu"
@ -11716,17 +11802,17 @@
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.ts",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'hex').toArrayBuffer();",
"lineNumber": 29,
"lineNumber": 30,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
"updated": "2020-05-28T18:08:02.658Z"
},
{
"rule": "jQuery-wrap(",
"path": "ts/textsecure/SendMessage.ts",
"line": " return window.dcodeIO.ByteBuffer.wrap(string, 'base64').toArrayBuffer();",
"lineNumber": 32,
"lineNumber": 33,
"reasonCategory": "falseMatch",
"updated": "2020-04-05T23:45:16.746Z"
"updated": "2020-05-28T18:08:02.658Z"
},
{
"rule": "jQuery-wrap(",

50
ts/window.d.ts vendored
View file

@ -1,5 +1,6 @@
// Captures the globals put in place by preload.js, background.js and others
import { Ref } from 'react';
import {
LibSignalType,
SignalProtocolAddressClass,
@ -7,7 +8,10 @@ import {
} from './libsignal.d';
import { TextSecureType } from './textsecure.d';
import { WebAPIConnectType } from './textsecure/WebAPI';
import { CallingClass, CallHistoryDetailsType } from './services/calling';
import * as Crypto from './Crypto';
import { ColorType, LocalizerType } from './types/Util';
import { SendOptionsType } from './textsecure/SendMessage';
declare global {
interface Window {
@ -15,6 +19,14 @@ declare global {
getExpiration: () => string;
getEnvironment: () => string;
getSocketStatus: () => number;
getAlwaysRelayCalls: () => Promise<boolean>;
getIncomingCallNotification: () => Promise<boolean>;
getCallRingtoneNotification: () => Promise<boolean>;
getCallSystemNotification: () => Promise<boolean>;
getMediaPermissions: () => Promise<boolean>;
getMediaCameraPermissions: () => Promise<boolean>;
showCallingPermissionsPopup: (forCamera: boolean) => Promise<void>;
i18n: LocalizerType;
libphonenumber: {
util: {
getRegionCodeForNumber: (number: string) => string;
@ -27,7 +39,9 @@ declare global {
error: LoggerType;
};
normalizeUuids: (obj: any, paths: Array<string>, context: string) => any;
platform: string;
restart: () => void;
showWindow: () => void;
storage: {
put: (key: string, value: any) => void;
remove: (key: string) => void;
@ -43,10 +57,16 @@ declare global {
trustRoot: ArrayBuffer
) => CertificateValidatorType;
};
Services: {
calling: CallingClass;
};
};
ConversationController: ConversationControllerType;
WebAPI: WebAPIConnectType;
Whisper: WhisperType;
// Flags
CALLING: boolean;
}
interface Error {
@ -58,6 +78,17 @@ export type ConversationType = {
updateE164: (e164?: string) => void;
updateUuid: (uuid?: string) => void;
id: string;
get: (key: string) => any;
getAvatarPath(): string | undefined;
getColor(): ColorType | undefined;
getName(): string | undefined;
getNumber(): string;
getProfileName(): string | undefined;
getRecipients: () => Array<string>;
getSendOptions(): SendOptionsType;
safeGetVerified(): Promise<number>;
getIsAddedByContact(): boolean;
addCallHistory(details: CallHistoryDetailsType): void;
};
export type ConversationControllerType = {
@ -73,11 +104,7 @@ export type ConversationControllerType = {
wrap: (promise: Promise<any>) => Promise<void>;
sendOptions: Object;
};
get: (
identifier: string
) => null | {
get: (key: string) => any;
};
get: (identifier: string) => null | ConversationType;
};
export type DCodeIOType = {
@ -130,6 +157,19 @@ export class ByteBufferClass {
skip: (length: number) => void;
}
export class GumVideoCapturer {
constructor(
maxWidth: number,
maxHeight: number,
maxFramerate: number,
localPreview: Ref<HTMLVideoElement>
);
}
export class CanvasVideoRenderer {
constructor(canvas: Ref<HTMLCanvasElement>);
}
export type LoggerType = (...args: Array<any>) => void;
export type WhisperType = {