signal-desktop/ts/state/ducks/calling.ts

1283 lines
32 KiB
TypeScript
Raw Normal View History

2020-10-30 20:34:04 +00:00
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
2020-11-04 17:47:50 +00:00
import { ThunkAction } from 'redux-thunk';
2020-10-01 19:09:15 +00:00
import { CallEndedReason } from 'ringrtc';
import { has, omit } from 'lodash';
import { getOwn } from '../../util/getOwn';
2020-11-13 19:57:55 +00:00
import { missingCaseError } from '../../util/missingCaseError';
2020-06-04 18:16:19 +00:00
import { notify } from '../../services/notify';
2020-08-27 00:03:42 +00:00
import { calling } from '../../services/calling';
2020-11-04 17:47:50 +00:00
import { StateType as RootStateType } from '../reducer';
2020-08-27 00:03:42 +00:00
import {
2020-12-02 18:14:03 +00:00
CallingDeviceType,
2020-11-13 19:57:55 +00:00
CallMode,
2020-08-27 00:03:42 +00:00
CallState,
ChangeIODevicePayloadType,
2020-11-13 19:57:55 +00:00
GroupCallConnectionState,
GroupCallJoinState,
GroupCallVideoRequest,
2020-08-27 00:03:42 +00:00
MediaDeviceSettings,
} from '../../types/Calling';
2020-06-04 18:16:19 +00:00
import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions';
import {
bounceAppIconStart,
bounceAppIconStop,
} from '../../shims/bounceAppIcon';
2020-11-20 17:19:28 +00:00
import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue';
2020-06-04 18:16:19 +00:00
// State
2020-11-20 17:19:28 +00:00
export interface GroupCallPeekInfoType {
2020-12-02 18:14:03 +00:00
uuids: Array<string>;
creatorUuid?: string;
2020-11-20 17:19:28 +00:00
eraId?: string;
maxDevices: number;
deviceCount: number;
}
2020-11-17 15:07:53 +00:00
export interface GroupCallParticipantInfoType {
2020-12-02 18:14:03 +00:00
uuid: string;
2020-11-17 15:07:53 +00:00
demuxId: number;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
speakerTime?: number;
2020-11-17 15:07:53 +00:00
videoAspectRatio: number;
}
export interface DirectCallStateType {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Direct;
conversationId: string;
callState?: CallState;
callEndedReason?: CallEndedReason;
2020-06-04 18:16:19 +00:00
isIncoming: boolean;
isVideoCall: boolean;
hasRemoteVideo?: boolean;
}
2020-07-24 01:35:32 +00:00
2020-11-13 19:57:55 +00:00
export interface GroupCallStateType {
callMode: CallMode.Group;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
2020-11-20 17:19:28 +00:00
peekInfo: GroupCallPeekInfoType;
2020-11-17 15:07:53 +00:00
remoteParticipants: Array<GroupCallParticipantInfoType>;
2020-11-13 19:57:55 +00:00
}
export interface ActiveCallStateType {
conversationId: string;
joinedAt?: number;
2020-06-04 18:16:19 +00:00
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2020-11-17 15:07:53 +00:00
showParticipantsList: boolean;
2020-10-01 00:43:05 +00:00
pip: boolean;
2020-08-27 00:03:42 +00:00
settingsDialogOpen: boolean;
}
export interface CallsByConversationType {
[conversationId: string]: DirectCallStateType | GroupCallStateType;
}
export type CallingStateType = MediaDeviceSettings & {
callsByConversation: CallsByConversationType;
activeCallState?: ActiveCallStateType;
2020-06-04 18:16:19 +00:00
};
export type AcceptCallType = {
conversationId: string;
2020-06-04 18:16:19 +00:00
asVideoCall: boolean;
};
export type CallStateChangeType = {
conversationId: string;
acceptedTime?: number;
2020-06-04 18:16:19 +00:00
callState: CallState;
2020-10-01 19:09:15 +00:00
callEndedReason?: CallEndedReason;
isIncoming: boolean;
isVideoCall: boolean;
title: string;
2020-06-04 18:16:19 +00:00
};
2020-11-13 19:57:55 +00:00
export type CancelCallType = {
conversationId: string;
};
2020-06-04 18:16:19 +00:00
export type DeclineCallType = {
conversationId: string;
2020-06-04 18:16:19 +00:00
};
2020-11-20 17:19:28 +00:00
type GroupCallStateChangeArgumentType = {
2020-11-13 19:57:55 +00:00
connectionState: GroupCallConnectionState;
2020-11-20 17:19:28 +00:00
conversationId: string;
2020-11-13 19:57:55 +00:00
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2020-11-20 17:19:28 +00:00
joinState: GroupCallJoinState;
2020-12-02 18:14:03 +00:00
peekInfo?: GroupCallPeekInfoType;
2020-11-17 15:07:53 +00:00
remoteParticipants: Array<GroupCallParticipantInfoType>;
2020-11-13 19:57:55 +00:00
};
2020-11-20 17:19:28 +00:00
type GroupCallStateChangeActionPayloadType = GroupCallStateChangeArgumentType & {
2020-12-02 18:14:03 +00:00
ourUuid: string;
2020-11-20 17:19:28 +00:00
};
2020-06-04 18:16:19 +00:00
export type HangUpType = {
conversationId: string;
2020-06-04 18:16:19 +00:00
};
export type IncomingCallType = {
conversationId: string;
isVideoCall: boolean;
2020-06-04 18:16:19 +00:00
};
2020-11-20 17:19:28 +00:00
type PeekNotConnectedGroupCallType = {
conversationId: string;
};
2020-11-13 19:57:55 +00:00
interface StartDirectCallType {
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2020-11-13 19:57:55 +00:00
}
export interface StartCallType extends StartDirectCallType {
callMode: CallMode.Direct | CallMode.Group;
}
2020-06-04 18:16:19 +00:00
export type RemoteVideoChangeType = {
conversationId: string;
hasVideo: boolean;
2020-06-04 18:16:19 +00:00
};
export type SetLocalAudioType = {
enabled: boolean;
};
export type SetLocalVideoType = {
enabled: boolean;
};
export type SetGroupCallVideoRequestType = {
conversationId: string;
resolutions: Array<GroupCallVideoRequest>;
};
2020-11-13 19:57:55 +00:00
export type ShowCallLobbyType =
| {
callMode: CallMode.Direct;
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}
| {
callMode: CallMode.Group;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2020-12-02 18:14:03 +00:00
peekInfo?: GroupCallPeekInfoType;
2020-11-17 15:07:53 +00:00
remoteParticipants: Array<GroupCallParticipantInfoType>;
2020-11-13 19:57:55 +00:00
};
2020-08-27 00:03:42 +00:00
export type SetLocalPreviewType = {
element: React.RefObject<HTMLVideoElement> | undefined;
2020-06-04 18:16:19 +00:00
};
2020-08-27 00:03:42 +00:00
export type SetRendererCanvasType = {
element: React.RefObject<HTMLCanvasElement> | undefined;
2020-06-04 18:16:19 +00:00
};
2020-10-30 17:52:21 +00:00
// Helpers
2020-11-13 19:57:55 +00:00
export const getActiveCall = ({
activeCallState,
callsByConversation,
}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType =>
activeCallState &&
getOwn(callsByConversation, activeCallState.conversationId);
2020-11-20 17:19:28 +00:00
export const isAnybodyElseInGroupCall = (
2020-12-02 18:14:03 +00:00
{ uuids }: Readonly<GroupCallPeekInfoType>,
ourUuid: string
): boolean => uuids.some(id => id !== ourUuid);
2020-11-20 17:19:28 +00:00
2020-06-04 18:16:19 +00:00
// Actions
2020-11-04 17:47:50 +00:00
const ACCEPT_CALL_PENDING = 'calling/ACCEPT_CALL_PENDING';
2020-10-08 01:25:33 +00:00
const CANCEL_CALL = 'calling/CANCEL_CALL';
const SHOW_CALL_LOBBY = 'calling/SHOW_CALL_LOBBY';
const CALL_STATE_CHANGE_FULFILLED = 'calling/CALL_STATE_CHANGE_FULFILLED';
2020-08-27 00:03:42 +00:00
const CHANGE_IO_DEVICE_FULFILLED = 'calling/CHANGE_IO_DEVICE_FULFILLED';
2020-10-01 19:09:15 +00:00
const CLOSE_NEED_PERMISSION_SCREEN = 'calling/CLOSE_NEED_PERMISSION_SCREEN';
2020-06-04 18:16:19 +00:00
const DECLINE_CALL = 'calling/DECLINE_CALL';
2020-11-13 19:57:55 +00:00
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
2020-06-04 18:16:19 +00:00
const HANG_UP = 'calling/HANG_UP';
const INCOMING_CALL = 'calling/INCOMING_CALL';
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
2020-11-20 17:19:28 +00:00
const PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED =
'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
2020-08-27 00:03:42 +00:00
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
2020-06-04 18:16:19 +00:00
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const SET_LOCAL_AUDIO_FULFILLED = 'calling/SET_LOCAL_AUDIO_FULFILLED';
2020-06-04 18:16:19 +00:00
const SET_LOCAL_VIDEO_FULFILLED = 'calling/SET_LOCAL_VIDEO_FULFILLED';
2020-11-13 19:57:55 +00:00
const START_DIRECT_CALL = 'calling/START_DIRECT_CALL';
2020-10-08 01:25:33 +00:00
const TOGGLE_PARTICIPANTS = 'calling/TOGGLE_PARTICIPANTS';
2020-10-01 00:43:05 +00:00
const TOGGLE_PIP = 'calling/TOGGLE_PIP';
2020-08-27 00:03:42 +00:00
const TOGGLE_SETTINGS = 'calling/TOGGLE_SETTINGS';
2020-06-04 18:16:19 +00:00
2020-11-04 17:47:50 +00:00
type AcceptCallPendingActionType = {
type: 'calling/ACCEPT_CALL_PENDING';
2020-06-04 18:16:19 +00:00
payload: AcceptCallType;
};
2020-10-08 01:25:33 +00:00
type CancelCallActionType = {
type: 'calling/CANCEL_CALL';
};
type CallLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY';
payload: ShowCallLobbyType;
2020-10-08 01:25:33 +00:00
};
type CallStateChangeFulfilledActionType = {
type: 'calling/CALL_STATE_CHANGE_FULFILLED';
2020-06-04 18:16:19 +00:00
payload: CallStateChangeType;
};
2020-08-27 00:03:42 +00:00
type ChangeIODeviceFulfilledActionType = {
type: 'calling/CHANGE_IO_DEVICE_FULFILLED';
payload: ChangeIODevicePayloadType;
};
2020-10-01 19:09:15 +00:00
type CloseNeedPermissionScreenActionType = {
type: 'calling/CLOSE_NEED_PERMISSION_SCREEN';
payload: null;
};
2020-06-04 18:16:19 +00:00
type DeclineCallActionType = {
type: 'calling/DECLINE_CALL';
payload: DeclineCallType;
};
2020-11-20 17:19:28 +00:00
export type GroupCallStateChangeActionType = {
2020-11-13 19:57:55 +00:00
type: 'calling/GROUP_CALL_STATE_CHANGE';
2020-11-20 17:19:28 +00:00
payload: GroupCallStateChangeActionPayloadType;
2020-11-13 19:57:55 +00:00
};
2020-06-04 18:16:19 +00:00
type HangUpActionType = {
type: 'calling/HANG_UP';
payload: HangUpType;
};
type IncomingCallActionType = {
type: 'calling/INCOMING_CALL';
payload: IncomingCallType;
};
type OutgoingCallActionType = {
type: 'calling/OUTGOING_CALL';
2020-11-13 19:57:55 +00:00
payload: StartDirectCallType;
2020-06-04 18:16:19 +00:00
};
2020-11-20 17:19:28 +00:00
type PeekNotConnectedGroupCallFulfilledActionType = {
type: 'calling/PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED';
payload: {
conversationId: string;
peekInfo: GroupCallPeekInfoType;
ourConversationId: string;
};
};
2020-08-27 00:03:42 +00:00
type RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES';
payload: MediaDeviceSettings;
};
2020-06-04 18:16:19 +00:00
type RemoteVideoChangeActionType = {
type: 'calling/REMOTE_VIDEO_CHANGE';
payload: RemoteVideoChangeType;
};
type SetLocalAudioActionType = {
type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
2020-06-04 18:16:19 +00:00
payload: SetLocalAudioType;
};
type SetLocalVideoFulfilledActionType = {
type: 'calling/SET_LOCAL_VIDEO_FULFILLED';
payload: SetLocalVideoType;
};
type ShowCallLobbyActionType = {
type: 'calling/SHOW_CALL_LOBBY';
payload: ShowCallLobbyType;
};
2020-11-13 19:57:55 +00:00
type StartDirectCallActionType = {
type: 'calling/START_DIRECT_CALL';
payload: StartDirectCallType;
2020-10-08 01:25:33 +00:00
};
type ToggleParticipantsActionType = {
type: 'calling/TOGGLE_PARTICIPANTS';
};
2020-10-01 00:43:05 +00:00
type TogglePipActionType = {
type: 'calling/TOGGLE_PIP';
};
2020-08-27 00:03:42 +00:00
type ToggleSettingsActionType = {
type: 'calling/TOGGLE_SETTINGS';
};
2020-06-04 18:16:19 +00:00
export type CallingActionType =
2020-11-04 17:47:50 +00:00
| AcceptCallPendingActionType
2020-10-08 01:25:33 +00:00
| CancelCallActionType
| CallLobbyActionType
| CallStateChangeFulfilledActionType
2020-08-27 00:03:42 +00:00
| ChangeIODeviceFulfilledActionType
2020-10-01 19:09:15 +00:00
| CloseNeedPermissionScreenActionType
2020-06-04 18:16:19 +00:00
| DeclineCallActionType
2020-11-13 19:57:55 +00:00
| GroupCallStateChangeActionType
2020-06-04 18:16:19 +00:00
| HangUpActionType
| IncomingCallActionType
| OutgoingCallActionType
2020-11-20 17:19:28 +00:00
| PeekNotConnectedGroupCallFulfilledActionType
2020-08-27 00:03:42 +00:00
| RefreshIODevicesActionType
2020-06-04 18:16:19 +00:00
| RemoteVideoChangeActionType
| SetLocalAudioActionType
2020-08-27 00:03:42 +00:00
| SetLocalVideoFulfilledActionType
| ShowCallLobbyActionType
2020-11-13 19:57:55 +00:00
| StartDirectCallActionType
2020-10-08 01:25:33 +00:00
| ToggleParticipantsActionType
2020-10-01 00:43:05 +00:00
| TogglePipActionType
2020-08-27 00:03:42 +00:00
| ToggleSettingsActionType;
2020-06-04 18:16:19 +00:00
// Action Creators
function acceptCall(
payload: AcceptCallType
2020-11-04 17:47:50 +00:00
): ThunkAction<void, RootStateType, unknown, AcceptCallPendingActionType> {
return async dispatch => {
dispatch({
type: ACCEPT_CALL_PENDING,
payload,
});
2020-06-04 18:16:19 +00:00
try {
await calling.accept(payload.conversationId, payload.asVideoCall);
2020-06-04 18:16:19 +00:00
} catch (err) {
window.log.error(`Failed to acceptCall: ${err.stack}`);
}
};
}
function callStateChange(
payload: CallStateChangeType
2020-11-04 17:47:50 +00:00
): ThunkAction<
void,
RootStateType,
unknown,
CallStateChangeFulfilledActionType
> {
return async dispatch => {
const { callState, isIncoming, title, isVideoCall } = payload;
2020-11-04 17:47:50 +00:00
if (callState === CallState.Ringing && isIncoming) {
await callingTones.playRingtone();
await showCallNotification(title, isVideoCall);
2020-11-04 17:47:50 +00:00
bounceAppIconStart();
}
if (callState !== CallState.Ringing) {
await callingTones.stopRingtone();
bounceAppIconStop();
}
if (callState === CallState.Ended) {
await callingTones.playEndCall();
}
dispatch({
type: CALL_STATE_CHANGE_FULFILLED,
payload,
});
};
}
2020-08-27 00:03:42 +00:00
function changeIODevice(
payload: ChangeIODevicePayloadType
2020-11-04 17:47:50 +00:00
): ThunkAction<
void,
RootStateType,
unknown,
ChangeIODeviceFulfilledActionType
> {
return async dispatch => {
// Only `setPreferredCamera` returns a Promise.
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);
}
dispatch({
type: CHANGE_IO_DEVICE_FULFILLED,
payload,
});
2020-08-27 00:03:42 +00:00
};
}
async function showCallNotification(
title: string,
isVideoCall: boolean
): Promise<void> {
2020-06-04 18:16:19 +00:00
const canNotify = await window.getCallSystemNotification();
if (!canNotify) {
return;
}
notify({
2020-07-24 01:35:32 +00:00
title,
2020-06-04 18:16:19 +00:00
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,
});
}
2020-10-01 19:09:15 +00:00
function closeNeedPermissionScreen(): CloseNeedPermissionScreenActionType {
return {
type: CLOSE_NEED_PERMISSION_SCREEN,
payload: null,
};
}
2020-11-13 19:57:55 +00:00
function cancelCall(payload: CancelCallType): CancelCallActionType {
calling.stopCallingLobby(payload.conversationId);
2020-10-08 01:25:33 +00:00
return {
type: CANCEL_CALL,
};
}
2020-06-04 18:16:19 +00:00
function declineCall(payload: DeclineCallType): DeclineCallActionType {
calling.decline(payload.conversationId);
2020-06-04 18:16:19 +00:00
return {
type: DECLINE_CALL,
payload,
};
}
2020-11-13 19:57:55 +00:00
function groupCallStateChange(
2020-11-20 17:19:28 +00:00
payload: GroupCallStateChangeArgumentType
): ThunkAction<void, RootStateType, unknown, GroupCallStateChangeActionType> {
return (dispatch, getState) => {
dispatch({
type: GROUP_CALL_STATE_CHANGE,
payload: {
...payload,
2020-12-02 18:14:03 +00:00
ourUuid: getState().user.ourUuid,
2020-11-20 17:19:28 +00:00
},
});
2020-11-13 19:57:55 +00:00
};
}
2020-06-04 18:16:19 +00:00
function hangUp(payload: HangUpType): HangUpActionType {
calling.hangup(payload.conversationId);
2020-06-04 18:16:19 +00:00
return {
type: HANG_UP,
payload,
};
}
function receiveIncomingCall(
payload: IncomingCallType
): IncomingCallActionType {
2020-06-04 18:16:19 +00:00
return {
type: INCOMING_CALL,
payload,
};
}
2020-11-13 19:57:55 +00:00
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
2020-06-04 18:16:19 +00:00
callingTones.playRingtone();
return {
type: OUTGOING_CALL,
payload,
};
}
2020-11-20 17:19:28 +00:00
// We might call this function many times in rapid succession (for example, if lots of
// people are joining and leaving at once). We want to make sure to update eventually
// (if people join and leave for an hour, we don't want you to have to wait an hour to
// get an update), and we also don't want to update too often. That's why we use a
// "latest queue".
const peekQueueByConversation = new Map<string, LatestQueue>();
function peekNotConnectedGroupCall(
payload: PeekNotConnectedGroupCallType
): ThunkAction<
void,
RootStateType,
unknown,
PeekNotConnectedGroupCallFulfilledActionType
> {
return (dispatch, getState) => {
const { conversationId } = payload;
let queue = peekQueueByConversation.get(conversationId);
if (!queue) {
queue = new LatestQueue();
queue.onceEmpty(() => {
peekQueueByConversation.delete(conversationId);
});
peekQueueByConversation.set(conversationId, queue);
}
queue.add(async () => {
const state = getState();
// We make sure we're not trying to peek at a connected (or connecting, or
// reconnecting) call. Because this is asynchronous, it's possible that the call
// will connect by the time we dispatch, so we also need to do a similar check in
// the reducer.
const existingCall = getOwn(
state.calling.callsByConversation,
conversationId
);
if (
existingCall?.callMode === CallMode.Group &&
existingCall.connectionState !== GroupCallConnectionState.NotConnected
) {
return;
}
// If we peek right after receiving the message, we may get outdated information.
// This is most noticeable when someone leaves. We add a delay and then make sure
// to only be peeking once.
await sleep(1000);
let peekInfo;
try {
peekInfo = await calling.peekGroupCall(conversationId);
} catch (err) {
window.log.error('Group call peeking failed', err);
return;
}
if (!peekInfo) {
return;
}
dispatch({
type: PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED,
payload: {
conversationId,
peekInfo: calling.formatGroupCallPeekInfoForRedux(peekInfo),
ourConversationId: state.user.ourConversationId,
},
});
});
};
}
2020-08-27 00:03:42 +00:00
function refreshIODevices(
payload: MediaDeviceSettings
): RefreshIODevicesActionType {
return {
type: REFRESH_IO_DEVICES,
payload,
};
}
2020-06-04 18:16:19 +00:00
function remoteVideoChange(
payload: RemoteVideoChangeType
): RemoteVideoChangeActionType {
return {
type: REMOTE_VIDEO_CHANGE,
payload,
};
}
2020-11-04 17:47:50 +00:00
function setLocalPreview(
payload: SetLocalPreviewType
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
calling.videoCapturer.setLocalPreview(payload.element);
2020-06-04 18:16:19 +00:00
};
}
2020-11-04 17:47:50 +00:00
function setRendererCanvas(
payload: SetRendererCanvasType
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
calling.videoRenderer.setCanvas(payload.element);
2020-06-04 18:16:19 +00:00
};
}
function setLocalAudio(
payload: SetLocalAudioType
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
return (dispatch, getState) => {
2020-11-13 19:57:55 +00:00
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
window.log.warn('Trying to set local audio when no call is active');
return;
}
2020-06-04 18:16:19 +00:00
2020-11-13 19:57:55 +00:00
calling.setOutgoingAudio(activeCall.conversationId, payload.enabled);
dispatch({
type: SET_LOCAL_AUDIO_FULFILLED,
payload,
});
2020-06-04 18:16:19 +00:00
};
}
2020-11-04 17:47:50 +00:00
function setLocalVideo(
payload: SetLocalVideoType
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
return async (dispatch, getState) => {
2020-11-13 19:57:55 +00:00
const activeCall = getActiveCall(getState().calling);
if (!activeCall) {
window.log.warn('Trying to set local video when no call is active');
return;
}
2020-11-04 17:47:50 +00:00
let enabled: boolean;
if (await requestCameraPermissions()) {
2020-11-13 19:57:55 +00:00
if (
activeCall.callMode === CallMode.Group ||
(activeCall.callMode === CallMode.Direct && activeCall.callState)
) {
calling.setOutgoingVideo(activeCall.conversationId, payload.enabled);
2020-11-04 17:47:50 +00:00
} else if (payload.enabled) {
calling.enableLocalCamera();
} else {
calling.disableLocalCamera();
}
({ enabled } = payload);
} else {
enabled = false;
}
dispatch({
type: SET_LOCAL_VIDEO_FULFILLED,
payload: {
...payload,
enabled,
},
});
2020-06-04 18:16:19 +00:00
};
}
function setGroupCallVideoRequest(
payload: SetGroupCallVideoRequestType
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
calling.setGroupCallVideoRequest(
payload.conversationId,
payload.resolutions.map(resolution => ({
...resolution,
// The `framerate` property in RingRTC has to be set, even if it's set to
// `undefined`.
framerate: undefined,
}))
);
};
}
function showCallLobby(payload: ShowCallLobbyType): CallLobbyActionType {
2020-10-08 01:25:33 +00:00
return {
type: SHOW_CALL_LOBBY,
payload,
};
}
2020-11-13 19:57:55 +00:00
function startCall(
payload: StartCallType
): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
return dispatch => {
switch (payload.callMode) {
case CallMode.Direct:
calling.startOutgoingDirectCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
dispatch({
type: START_DIRECT_CALL,
payload,
});
break;
case CallMode.Group:
calling.joinGroupCall(
payload.conversationId,
payload.hasLocalAudio,
payload.hasLocalVideo
);
// The calling service should already be wired up to Redux so we don't need to
// dispatch anything here.
break;
default:
throw missingCaseError(payload.callMode);
}
2020-10-08 01:25:33 +00:00
};
}
function toggleParticipants(): ToggleParticipantsActionType {
return {
type: TOGGLE_PARTICIPANTS,
};
}
2020-10-01 00:43:05 +00:00
function togglePip(): TogglePipActionType {
return {
type: TOGGLE_PIP,
};
}
2020-08-27 00:03:42 +00:00
function toggleSettings(): ToggleSettingsActionType {
return {
type: TOGGLE_SETTINGS,
};
}
2020-06-04 18:16:19 +00:00
export const actions = {
acceptCall,
2020-10-08 01:25:33 +00:00
cancelCall,
2020-06-04 18:16:19 +00:00
callStateChange,
2020-08-27 00:03:42 +00:00
changeIODevice,
2020-10-01 19:09:15 +00:00
closeNeedPermissionScreen,
2020-06-04 18:16:19 +00:00
declineCall,
2020-11-13 19:57:55 +00:00
groupCallStateChange,
2020-06-04 18:16:19 +00:00
hangUp,
receiveIncomingCall,
2020-06-04 18:16:19 +00:00
outgoingCall,
2020-11-20 17:19:28 +00:00
peekNotConnectedGroupCall,
2020-08-27 00:03:42 +00:00
refreshIODevices,
2020-06-04 18:16:19 +00:00
remoteVideoChange,
2020-08-27 00:03:42 +00:00
setLocalPreview,
setRendererCanvas,
2020-06-04 18:16:19 +00:00
setLocalAudio,
setLocalVideo,
setGroupCallVideoRequest,
2020-10-08 01:25:33 +00:00
showCallLobby,
startCall,
toggleParticipants,
2020-10-01 00:43:05 +00:00
togglePip,
2020-08-27 00:03:42 +00:00
toggleSettings,
2020-06-04 18:16:19 +00:00
};
export type ActionsType = typeof actions;
// Reducer
2020-10-30 17:52:21 +00:00
export function getEmptyState(): CallingStateType {
2020-06-04 18:16:19 +00:00
return {
2020-08-27 00:03:42 +00:00
availableCameras: [],
availableMicrophones: [],
availableSpeakers: [],
selectedCamera: undefined,
selectedMicrophone: undefined,
selectedSpeaker: undefined,
callsByConversation: {},
activeCallState: undefined,
};
}
2020-12-02 18:14:03 +00:00
function getExistingPeekInfo(
conversationId: string,
state: CallingStateType
): undefined | GroupCallPeekInfoType {
const existingCall = getOwn(state.callsByConversation, conversationId);
return existingCall?.callMode === CallMode.Group
? existingCall.peekInfo
: undefined;
}
function removeConversationFromState(
state: CallingStateType,
conversationId: string
): CallingStateType {
return {
...(conversationId === state.activeCallState?.conversationId
? omit(state, 'activeCallState')
: state),
callsByConversation: omit(state.callsByConversation, conversationId),
2020-06-04 18:16:19 +00:00
};
}
export function reducer(
state: CallingStateType = getEmptyState(),
action: CallingActionType
): CallingStateType {
const { callsByConversation } = state;
2020-10-08 01:25:33 +00:00
if (action.type === SHOW_CALL_LOBBY) {
2020-11-13 19:57:55 +00:00
let call: DirectCallStateType | GroupCallStateType;
switch (action.payload.callMode) {
case CallMode.Direct:
call = {
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
};
break;
case CallMode.Group:
// We expect to be in this state briefly. The Calling service should update the
// call state shortly.
call = {
callMode: CallMode.Group,
conversationId: action.payload.conversationId,
connectionState: action.payload.connectionState,
joinState: action.payload.joinState,
2020-12-02 18:14:03 +00:00
peekInfo: action.payload.peekInfo ||
getExistingPeekInfo(action.payload.conversationId, state) || {
uuids: action.payload.remoteParticipants.map(({ uuid }) => uuid),
maxDevices: Infinity,
deviceCount: action.payload.remoteParticipants.length,
},
2020-11-13 19:57:55 +00:00
remoteParticipants: action.payload.remoteParticipants,
};
break;
default:
throw missingCaseError(action.payload);
}
2020-10-08 01:25:33 +00:00
return {
...state,
callsByConversation: {
...callsByConversation,
2020-11-13 19:57:55 +00:00
[action.payload.conversationId]: call,
},
activeCallState: {
conversationId: action.payload.conversationId,
2020-11-13 19:57:55 +00:00
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2020-11-17 15:07:53 +00:00
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
},
2020-10-08 01:25:33 +00:00
};
}
2020-11-13 19:57:55 +00:00
if (action.type === START_DIRECT_CALL) {
2020-10-08 01:25:33 +00:00
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
},
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2020-11-17 15:07:53 +00:00
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
},
2020-10-08 01:25:33 +00:00
};
}
2020-11-04 17:47:50 +00:00
if (action.type === ACCEPT_CALL_PENDING) {
if (!has(state.callsByConversation, action.payload.conversationId)) {
window.log.warn('Unable to accept a non-existent call');
return state;
}
2020-06-04 18:16:19 +00:00
return {
...state,
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
2020-11-17 15:07:53 +00:00
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
},
2020-06-04 18:16:19 +00:00
};
}
2020-10-01 19:09:15 +00:00
if (
2020-10-08 01:25:33 +00:00
action.type === CANCEL_CALL ||
2020-10-01 19:09:15 +00:00
action.type === HANG_UP ||
action.type === CLOSE_NEED_PERMISSION_SCREEN
) {
2020-11-13 19:57:55 +00:00
const activeCall = getActiveCall(state);
if (!activeCall) {
window.log.warn('No active call to remove');
return state;
}
2020-11-13 19:57:55 +00:00
switch (activeCall.callMode) {
case CallMode.Direct:
return removeConversationFromState(state, activeCall.conversationId);
case CallMode.Group:
return omit(state, 'activeCallState');
default:
throw missingCaseError(activeCall);
}
}
if (action.type === DECLINE_CALL) {
return removeConversationFromState(state, action.payload.conversationId);
2020-06-04 18:16:19 +00:00
}
if (action.type === INCOMING_CALL) {
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: true,
isVideoCall: action.payload.isVideoCall,
},
},
2020-06-04 18:16:19 +00:00
};
}
if (action.type === OUTGOING_CALL) {
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
2020-11-13 19:57:55 +00:00
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
callState: CallState.Prering,
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
},
},
activeCallState: {
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2020-11-17 15:07:53 +00:00
showParticipantsList: false,
pip: false,
settingsDialogOpen: false,
},
2020-06-04 18:16:19 +00:00
};
}
if (action.type === CALL_STATE_CHANGE_FULFILLED) {
2020-10-01 19:09:15 +00:00
// We want to keep the state around for ended calls if they resulted in a message
// request so we can show the "needs permission" screen.
if (
action.payload.callState === CallState.Ended &&
action.payload.callEndedReason !==
CallEndedReason.RemoteHangupNeedPermission
) {
return removeConversationFromState(state, action.payload.conversationId);
2020-06-04 18:16:19 +00:00
}
const call = getOwn(
state.callsByConversation,
action.payload.conversationId
);
2020-11-13 19:57:55 +00:00
if (call?.callMode !== CallMode.Direct) {
window.log.warn('Cannot update state for a non-direct call');
return state;
}
let activeCallState: undefined | ActiveCallStateType;
if (
state.activeCallState?.conversationId === action.payload.conversationId
) {
activeCallState = {
...state.activeCallState,
joinedAt: action.payload.acceptedTime,
};
} else {
({ activeCallState } = state);
}
2020-06-04 18:16:19 +00:00
return {
...state,
callsByConversation: {
...callsByConversation,
[action.payload.conversationId]: {
...call,
callState: action.payload.callState,
callEndedReason: action.payload.callEndedReason,
},
},
activeCallState,
2020-06-04 18:16:19 +00:00
};
}
2020-11-13 19:57:55 +00:00
if (action.type === GROUP_CALL_STATE_CHANGE) {
const {
connectionState,
2020-11-20 17:19:28 +00:00
conversationId,
2020-11-13 19:57:55 +00:00
hasLocalAudio,
hasLocalVideo,
2020-11-20 17:19:28 +00:00
joinState,
2020-12-02 18:14:03 +00:00
ourUuid,
2020-11-20 17:19:28 +00:00
peekInfo,
2020-11-13 19:57:55 +00:00
remoteParticipants,
} = action.payload;
2020-12-02 18:14:03 +00:00
const newPeekInfo = peekInfo ||
getExistingPeekInfo(conversationId, state) || {
uuids: remoteParticipants.map(({ uuid }) => uuid),
maxDevices: Infinity,
deviceCount: remoteParticipants.length,
};
2020-11-20 17:19:28 +00:00
let newActiveCallState: ActiveCallStateType | undefined;
2020-11-13 19:57:55 +00:00
if (connectionState === GroupCallConnectionState.NotConnected) {
2020-11-20 17:19:28 +00:00
newActiveCallState =
state.activeCallState?.conversationId === conversationId
? undefined
: state.activeCallState;
2020-12-02 18:14:03 +00:00
if (!isAnybodyElseInGroupCall(newPeekInfo, ourUuid)) {
2020-11-20 17:19:28 +00:00
return {
...state,
callsByConversation: omit(callsByConversation, conversationId),
activeCallState: newActiveCallState,
};
}
} else {
newActiveCallState =
state.activeCallState?.conversationId === conversationId
? {
...state.activeCallState,
hasLocalAudio,
hasLocalVideo,
}
: state.activeCallState;
2020-11-13 19:57:55 +00:00
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
callMode: CallMode.Group,
conversationId,
connectionState,
joinState,
2020-12-02 18:14:03 +00:00
peekInfo: newPeekInfo,
2020-11-13 19:57:55 +00:00
remoteParticipants,
},
},
2020-11-20 17:19:28 +00:00
activeCallState: newActiveCallState,
};
}
if (action.type === PEEK_NOT_CONNECTED_GROUP_CALL_FULFILLED) {
const { conversationId, peekInfo, ourConversationId } = action.payload;
const existingCall = getOwn(state.callsByConversation, conversationId) || {
callMode: CallMode.Group,
conversationId,
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
peekInfo: {
conversationIds: [],
maxDevices: Infinity,
deviceCount: 0,
},
remoteParticipants: [],
};
if (existingCall.callMode !== CallMode.Group) {
window.log.error(
'Unexpected state: trying to update a non-group call. Doing nothing'
);
return state;
}
// This action should only update non-connected group calls. It's not necessarily a
// mistake if this action is dispatched "over" a connected call. Here's a valid
// sequence of events:
//
// 1. We ask RingRTC to peek, kicking off an asynchronous operation.
// 2. The associated group call is joined.
// 3. The peek promise from step 1 resolves.
if (
existingCall.connectionState !== GroupCallConnectionState.NotConnected
) {
return state;
}
if (!isAnybodyElseInGroupCall(peekInfo, ourConversationId)) {
return removeConversationFromState(state, conversationId);
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
...existingCall,
peekInfo,
},
},
2020-11-13 19:57:55 +00:00
};
}
2020-06-04 18:16:19 +00:00
if (action.type === REMOTE_VIDEO_CHANGE) {
const { conversationId, hasVideo } = action.payload;
const call = getOwn(state.callsByConversation, conversationId);
2020-11-13 19:57:55 +00:00
if (call?.callMode !== CallMode.Direct) {
window.log.warn('Cannot update remote video for a non-direct call');
return state;
}
2020-06-04 18:16:19 +00:00
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
...call,
hasRemoteVideo: hasVideo,
},
},
2020-06-04 18:16:19 +00:00
};
}
if (action.type === SET_LOCAL_AUDIO_FULFILLED) {
if (!state.activeCallState) {
window.log.warn('Cannot set local audio with no active call');
return state;
}
2020-06-04 18:16:19 +00:00
return {
...state,
activeCallState: {
...state.activeCallState,
hasLocalAudio: action.payload.enabled,
},
2020-06-04 18:16:19 +00:00
};
}
if (action.type === SET_LOCAL_VIDEO_FULFILLED) {
if (!state.activeCallState) {
window.log.warn('Cannot set local video with no active call');
return state;
}
2020-06-04 18:16:19 +00:00
return {
...state,
activeCallState: {
...state.activeCallState,
hasLocalVideo: action.payload.enabled,
},
2020-06-04 18:16:19 +00:00
};
}
2020-08-27 00:03:42 +00:00
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) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn('Cannot toggle settings when there is no active call');
return state;
}
2020-08-27 00:03:42 +00:00
return {
...state,
activeCallState: {
...activeCallState,
settingsDialogOpen: !activeCallState.settingsDialogOpen,
},
2020-08-27 00:03:42 +00:00
};
}
2020-10-08 01:25:33 +00:00
if (action.type === TOGGLE_PARTICIPANTS) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn(
'Cannot toggle participants list when there is no active call'
);
return state;
}
2020-10-08 01:25:33 +00:00
return {
...state,
activeCallState: {
...activeCallState,
2020-11-17 15:07:53 +00:00
showParticipantsList: !activeCallState.showParticipantsList,
},
2020-10-08 01:25:33 +00:00
};
}
2020-10-01 00:43:05 +00:00
if (action.type === TOGGLE_PIP) {
const { activeCallState } = state;
if (!activeCallState) {
window.log.warn('Cannot toggle PiP when there is no active call');
return state;
}
2020-10-01 00:43:05 +00:00
return {
...state,
activeCallState: {
...activeCallState,
pip: !activeCallState.pip,
},
2020-10-01 00:43:05 +00:00
};
}
2020-06-04 18:16:19 +00:00
return state;
}