signal-desktop/ts/state/ducks/calling.ts
2020-09-21 15:42:50 -04:00

552 lines
12 KiB
TypeScript

import { notify } from '../../services/notify';
import { calling } from '../../services/calling';
import {
CallingDeviceType,
CallState,
ChangeIODevicePayloadType,
MediaDeviceSettings,
} from '../../types/Calling';
import { ColorType } from '../../types/Colors';
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 = {
callId: CallId;
isIncoming: boolean;
isVideoCall: boolean;
avatarPath?: string;
color?: ColorType;
name?: string;
phoneNumber?: string;
profileName?: string;
title: string;
};
export type CallingStateType = MediaDeviceSettings & {
callDetails?: CallDetailsType;
callState?: CallState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
hasRemoteVideo: boolean;
settingsDialogOpen: 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 SetLocalPreviewType = {
element: React.RefObject<HTMLVideoElement> | undefined;
};
export type SetRendererCanvasType = {
element: React.RefObject<HTMLCanvasElement> | undefined;
};
// Actions
const ACCEPT_CALL = 'calling/ACCEPT_CALL';
const CALL_STATE_CHANGE = 'calling/CALL_STATE_CHANGE';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
const CHANGE_IO_DEVICE = 'calling/CHANGE_IO_DEVICE';
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
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 REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
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';
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
type AcceptCallActionType = {
type: 'calling/ACCEPT_CALL';
payload: AcceptCallType;
};
type CallStateChangeActionType = {
type: 'calling/CALL_STATE_CHANGE';
payload: Promise<CallStateChangeType>;
};
type CallStateChangeFulfilledActionType = {
type: 'calling/CALL_STATE_CHANGE_FULFILLED';
payload: CallStateChangeType;
};
type ChangeIODeviceActionType = {
type: 'calling/CHANGE_IO_DEVICE';
payload: Promise<ChangeIODevicePayloadType>;
};
type ChangeIODeviceFulfilledActionType = {
type: 'calling/CHANGE_IO_DEVICE_FULFILLED';
payload: ChangeIODevicePayloadType;
};
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 RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES';
payload: MediaDeviceSettings;
};
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;
};
type ToggleSettingsActionType = {
type: 'calling/TOGGLE_SETTINGS';
};
export type CallingActionType =
| AcceptCallActionType
| CallStateChangeActionType
| CallStateChangeFulfilledActionType
| ChangeIODeviceActionType
| ChangeIODeviceFulfilledActionType
| DeclineCallActionType
| HangUpActionType
| IncomingCallActionType
| OutgoingCallActionType
| RefreshIODevicesActionType
| RemoteVideoChangeActionType
| SetLocalAudioActionType
| SetLocalVideoActionType
| SetLocalVideoFulfilledActionType
| ToggleSettingsActionType;
// 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 {
return {
type: CALL_STATE_CHANGE,
payload: doCallStateChange(payload),
};
}
function changeIODevice(
payload: ChangeIODevicePayloadType
): ChangeIODeviceActionType {
return {
type: CHANGE_IO_DEVICE,
payload: doChangeIODevice(payload),
};
}
async function doChangeIODevice(
payload: ChangeIODevicePayloadType
): Promise<ChangeIODevicePayloadType> {
if (payload.type === CallingDeviceType.CAMERA) {
await calling.setPreferredCamera(payload.selectedDevice);
} else if (payload.type === CallingDeviceType.MICROPHONE) {
calling.setPreferredMicrophone(payload.selectedDevice);
} else if (payload.type === CallingDeviceType.SPEAKER) {
calling.setPreferredSpeaker(payload.selectedDevice);
}
return payload;
}
async function doCallStateChange(
payload: CallStateChangeType
): Promise<CallStateChangeType> {
const { callDetails, callState } = payload;
const { isIncoming } = callDetails;
if (callState === CallState.Ringing && isIncoming) {
await callingTones.playRingtone();
await showCallNotification(callDetails);
bounceAppIconStart();
}
if (callState !== CallState.Ringing) {
await callingTones.stopRingtone();
bounceAppIconStop();
}
if (callState === CallState.Ended) {
await callingTones.playEndCall();
}
return payload;
}
async function showCallNotification(callDetails: CallDetailsType) {
const canNotify = await window.getCallSystemNotification();
if (!canNotify) {
return;
}
const { title, isVideoCall } = callDetails;
notify({
title,
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 refreshIODevices(
payload: MediaDeviceSettings
): RefreshIODevicesActionType {
return {
type: REFRESH_IO_DEVICES,
payload,
};
}
function remoteVideoChange(
payload: RemoteVideoChangeType
): RemoteVideoChangeActionType {
return {
type: REMOTE_VIDEO_CHANGE,
payload,
};
}
function setLocalPreview(payload: SetLocalPreviewType): NoopActionType {
calling.videoCapturer.setLocalPreview(payload.element);
return {
type: 'NOOP',
payload: null,
};
}
function setRendererCanvas(payload: SetRendererCanvasType): NoopActionType {
calling.videoRenderer.setCanvas(payload.element);
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),
};
}
function toggleSettings(): ToggleSettingsActionType {
return {
type: TOGGLE_SETTINGS,
};
}
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,
changeIODevice,
declineCall,
hangUp,
incomingCall,
outgoingCall,
refreshIODevices,
remoteVideoChange,
setLocalPreview,
setRendererCanvas,
setLocalAudio,
setLocalVideo,
toggleSettings,
};
export type ActionsType = typeof actions;
// Reducer
function getEmptyState(): CallingStateType {
return {
availableCameras: [],
availableMicrophones: [],
availableSpeakers: [],
callDetails: undefined,
callState: undefined,
hasLocalAudio: false,
hasLocalVideo: false,
hasRemoteVideo: false,
selectedCamera: undefined,
selectedMicrophone: undefined,
selectedSpeaker: undefined,
settingsDialogOpen: false,
};
}
// tslint:disable-next-line max-func-body-length
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_FULFILLED) {
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,
};
}
if (action.type === CHANGE_IO_DEVICE_FULFILLED) {
const { selectedDevice } = action.payload;
const nextState = Object.create(null);
if (action.payload.type === CallingDeviceType.CAMERA) {
nextState.selectedCamera = selectedDevice;
} else if (action.payload.type === CallingDeviceType.MICROPHONE) {
nextState.selectedMicrophone = selectedDevice;
} else if (action.payload.type === CallingDeviceType.SPEAKER) {
nextState.selectedSpeaker = selectedDevice;
}
return {
...state,
...nextState,
};
}
if (action.type === REFRESH_IO_DEVICES) {
const {
availableMicrophones,
selectedMicrophone,
availableSpeakers,
selectedSpeaker,
availableCameras,
selectedCamera,
} = action.payload;
return {
...state,
availableMicrophones,
selectedMicrophone,
availableSpeakers,
selectedSpeaker,
availableCameras,
selectedCamera,
};
}
if (action.type === TOGGLE_SETTINGS) {
return {
...state,
settingsDialogOpen: !state.settingsDialogOpen,
};
}
return state;
}