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

3547 lines
96 KiB
TypeScript
Raw Normal View History

2023-01-03 19:55:46 +00:00
// Copyright 2020 Signal Messenger, LLC
2020-10-30 20:34:04 +00:00
// SPDX-License-Identifier: AGPL-3.0-only
import type { ThunkAction, ThunkDispatch } from 'redux-thunk';
import {
hasScreenCapturePermission,
openSystemPreferences,
} from 'mac-screen-capture-permissions';
2024-04-25 17:09:05 +00:00
import { omit, pick } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
2024-02-22 21:19:50 +00:00
import {
CallLinkRootKey,
GroupCallEndReason,
type Reaction as CallReaction,
} from '@signalapp/ringrtc';
2024-06-10 15:23:43 +00:00
import { v4 as generateUuid } from 'uuid';
import { getOwn } from '../../util/getOwn';
import * as Errors from '../../types/errors';
2024-02-22 21:19:50 +00:00
import { getIntl, getPlatform } from '../selectors/user';
2021-09-02 22:34:38 +00:00
import { isConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing';
2020-11-13 19:57:55 +00:00
import { missingCaseError } from '../../util/missingCaseError';
import { drop } from '../../util/drop';
2020-08-27 00:03:42 +00:00
import { calling } from '../../services/calling';
2022-05-19 03:28:51 +00:00
import { truncateAudioLevel } from '../../calling/truncateAudioLevel';
import type { StateType as RootStateType } from '../reducer';
import type {
2023-11-16 19:55:35 +00:00
ActiveCallReaction,
ActiveCallReactionsType,
ChangeIODevicePayloadType,
GroupCallVideoRequest,
MediaDeviceSettings,
PresentedSource,
PresentableSource,
} from '../../types/Calling';
2024-06-10 15:23:43 +00:00
import type {
CallLinkRestrictions,
CallLinkStateType,
CallLinkType,
} from '../../types/CallLink';
2020-08-27 00:03:42 +00:00
import {
2023-11-16 19:55:35 +00:00
CALLING_REACTIONS_LIFETIME,
MAX_CALLING_REACTIONS,
2023-08-09 00:53:06 +00:00
CallEndedReason,
2020-12-02 18:14:03 +00:00
CallingDeviceType,
2020-11-13 19:57:55 +00:00
CallMode,
CallViewMode,
2020-08-27 00:03:42 +00:00
CallState,
2020-11-13 19:57:55 +00:00
GroupCallConnectionState,
GroupCallJoinState,
2020-08-27 00:03:42 +00:00
} from '../../types/Calling';
2020-06-04 18:16:19 +00:00
import { callingTones } from '../../util/callingTones';
import { requestCameraPermissions } from '../../util/callingPermissions';
2024-04-25 17:09:05 +00:00
import {
CALL_LINK_DEFAULT_STATE,
getRoomIdFromRootKey,
2024-06-10 15:23:43 +00:00
isCallLinksCreateEnabled,
toAdminKeyBytes,
2024-04-25 17:09:05 +00:00
} from '../../util/callLinks';
import { sendCallLinkUpdateSync } from '../../util/sendCallLinkUpdateSync';
2020-11-20 17:19:28 +00:00
import { sleep } from '../../util/sleep';
import { LatestQueue } from '../../util/LatestQueue';
import type { AciString, ServiceIdString } from '../../types/ServiceId';
import type {
ConversationChangedActionType,
ConversationRemovedActionType,
} from './conversations';
import { getConversationCallMode, updateLastMessage } from './conversations';
import * as log from '../../logging/log';
import { strictAssert } from '../../util/assert';
import { waitForOnline } from '../../util/waitForOnline';
2022-05-19 03:28:51 +00:00
import * as mapUtil from '../../util/mapUtil';
import { isCallSafe } from '../../util/isCallSafe';
import { isDirectConversation } from '../../util/whatTypeOfConversation';
import { SHOW_TOAST } from './toast';
import { ToastType } from '../../types/Toast';
import type { ShowToastActionType } from './toast';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
2023-07-18 23:57:38 +00:00
import { isAnybodyElseInGroupCall } from './callingHelpers';
import { SafetyNumberChangeSource } from '../../components/SafetyNumberChangeDialog';
2024-02-22 21:19:50 +00:00
import {
isGroupOrAdhocCallMode,
isGroupOrAdhocCallState,
} from '../../util/isGroupOrAdhocCall';
import type { ShowErrorModalActionType } from './globalModals';
import { SHOW_ERROR_MODAL } from './globalModals';
import { ButtonVariant } from '../../components/Button';
import { getConversationIdForLogging } from '../../util/idForLogging';
2024-04-01 19:19:35 +00:00
import dataInterface from '../../sql/Client';
import { isAciString } from '../../util/isAciString';
2024-06-10 15:23:43 +00:00
import type { CallHistoryDetails } from '../../types/CallDisposition';
import {
AdhocCallStatus,
CallDirection,
CallType,
} from '../../types/CallDisposition';
import type { CallHistoryAdd } from './callHistory';
import { addCallHistory } from './callHistory';
import { saveDraftRecordingIfNeeded } from './composer';
2020-06-04 18:16:19 +00:00
// State
export type GroupCallPeekInfoType = ReadonlyDeep<{
acis: Array<AciString>;
pendingAcis: Array<AciString>;
creatorAci?: AciString;
2020-11-20 17:19:28 +00:00
eraId?: string;
maxDevices: number;
deviceCount: number;
}>;
2020-11-20 17:19:28 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type GroupCallParticipantInfoType = {
aci: AciString;
2024-01-23 19:08:21 +00:00
addedTime?: number;
2020-11-17 15:07:53 +00:00
demuxId: number;
hasRemoteAudio: boolean;
hasRemoteVideo: boolean;
2024-01-23 19:08:21 +00:00
mediaKeysReceived: boolean;
presenting: boolean;
sharingScreen: boolean;
speakerTime?: number;
2020-11-17 15:07:53 +00:00
videoAspectRatio: number;
};
2020-11-17 15:07:53 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type 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;
isSharingScreen?: boolean;
2020-06-04 18:16:19 +00:00
isVideoCall: boolean;
hasRemoteVideo?: boolean;
};
2020-07-24 01:35:32 +00:00
type GroupCallRingStateType = ReadonlyDeep<
2021-08-20 16:06:15 +00:00
| {
ringId?: undefined;
ringerAci?: undefined;
2021-08-20 16:06:15 +00:00
}
| {
ringId: bigint;
ringerAci: AciString;
}
>;
2021-08-20 16:06:15 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type GroupCallStateType = {
2024-02-22 21:19:50 +00:00
callMode: CallMode.Group | CallMode.Adhoc;
2020-11-13 19:57:55 +00:00
conversationId: string;
connectionState: GroupCallConnectionState;
2023-11-16 19:55:35 +00:00
localDemuxId: number | undefined;
2020-11-13 19:57:55 +00:00
joinState: GroupCallJoinState;
peekInfo?: GroupCallPeekInfoType;
2023-12-06 21:52:29 +00:00
raisedHands?: Array<number>;
2020-11-17 15:07:53 +00:00
remoteParticipants: Array<GroupCallParticipantInfoType>;
2022-05-19 03:28:51 +00:00
remoteAudioLevels?: Map<number, number>;
2021-08-20 16:06:15 +00:00
} & GroupCallRingStateType;
2020-11-13 19:57:55 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type ActiveCallStateType = {
2024-02-22 21:19:50 +00:00
callMode: CallMode;
conversationId: string;
2020-06-04 18:16:19 +00:00
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2022-05-19 03:28:51 +00:00
localAudioLevel: number;
viewMode: CallViewMode;
viewModeBeforePresentation?: CallViewMode;
joinedAt: number | null;
outgoingRing: boolean;
2020-10-01 00:43:05 +00:00
pip: boolean;
presentingSource?: PresentedSource;
presentingSourcesAvailable?: Array<PresentableSource>;
2021-01-08 22:57:54 +00:00
settingsDialogOpen: boolean;
showNeedsScreenRecordingPermissionsWarning?: boolean;
showParticipantsList: boolean;
2023-11-16 19:55:35 +00:00
reactions?: ActiveCallReactionsType;
};
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type CallsByConversationType = {
[conversationId: string]: DirectCallStateType | GroupCallStateType;
};
2024-02-22 21:19:50 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type AdhocCallsType = {
[roomId: string]: GroupCallStateType;
};
export type CallLinksByRoomIdType = ReadonlyDeep<{
2024-05-17 23:22:51 +00:00
[roomId: string]: CallLinkType;
2024-02-22 21:19:50 +00:00
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type CallingStateType = MediaDeviceSettings & {
callsByConversation: CallsByConversationType;
2024-02-22 21:19:50 +00:00
adhocCalls: AdhocCallsType;
callLinks: CallLinksByRoomIdType;
activeCallState?: ActiveCallStateType;
2020-06-04 18:16:19 +00:00
};
export type AcceptCallType = ReadonlyDeep<{
conversationId: string;
2020-06-04 18:16:19 +00:00
asVideoCall: boolean;
}>;
2020-06-04 18:16:19 +00:00
export type CallStateChangeType = ReadonlyDeep<{
conversationId: string;
acceptedTime: number | null;
2020-06-04 18:16:19 +00:00
callState: CallState;
2020-10-01 19:09:15 +00:00
callEndedReason?: CallEndedReason;
}>;
2020-06-04 18:16:19 +00:00
export type CancelCallType = ReadonlyDeep<{
2020-11-13 19:57:55 +00:00
conversationId: string;
}>;
2020-11-13 19:57:55 +00:00
type CancelIncomingGroupCallRingType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
conversationId: string;
ringId: bigint;
}>;
2021-08-20 16:06:15 +00:00
export type DeclineCallType = ReadonlyDeep<{
conversationId: string;
}>;
2020-06-04 18:16:19 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2020-11-20 17:19:28 +00:00
type GroupCallStateChangeArgumentType = {
2024-02-22 21:19:50 +00:00
callMode: CallMode.Group | CallMode.Adhoc;
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;
2023-11-16 19:55:35 +00:00
localDemuxId: number | undefined;
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
};
2023-11-16 19:55:35 +00:00
type GroupCallReactionsReceivedArgumentType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2023-11-16 19:55:35 +00:00
conversationId: string;
reactions: Array<CallReaction>;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2021-11-11 22:43:05 +00:00
type GroupCallStateChangeActionPayloadType =
GroupCallStateChangeArgumentType & {
ourAci: AciString;
2021-11-11 22:43:05 +00:00
};
2020-11-20 17:19:28 +00:00
2024-04-25 17:09:05 +00:00
type HandleCallLinkUpdateActionPayloadType = ReadonlyDeep<{
2024-05-17 23:22:51 +00:00
callLink: CallLinkType;
2024-04-25 17:09:05 +00:00
}>;
type HangUpActionPayloadType = ReadonlyDeep<{
conversationId: string;
}>;
2020-06-04 18:16:19 +00:00
export type HandleCallLinkUpdateType = ReadonlyDeep<{
2024-04-25 17:09:05 +00:00
rootKey: string;
adminKey: string | null;
}>;
export type IncomingDirectCallType = ReadonlyDeep<{
conversationId: string;
isVideoCall: boolean;
}>;
2020-06-04 18:16:19 +00:00
type IncomingGroupCallType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
conversationId: string;
ringId: bigint;
ringerAci: AciString;
}>;
2021-08-20 16:06:15 +00:00
2023-12-06 21:52:29 +00:00
export type SendGroupCallRaiseHandType = ReadonlyDeep<{
conversationId: string;
raise: boolean;
}>;
2023-11-16 19:55:35 +00:00
export type SendGroupCallReactionType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2023-11-16 19:55:35 +00:00
conversationId: string;
value: string;
}>;
type SendGroupCallReactionLocalCopyType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2023-11-16 19:55:35 +00:00
conversationId: string;
value: string;
timestamp: number;
}>;
export type PeekNotConnectedGroupCallType = ReadonlyDeep<{
callMode: CallMode.Group | CallMode.Adhoc;
2020-11-20 17:19:28 +00:00
conversationId: string;
}>;
2020-11-20 17:19:28 +00:00
type StartDirectCallType = ReadonlyDeep<{
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}>;
2020-11-13 19:57:55 +00:00
export type StartCallType = ReadonlyDeep<
StartDirectCallType & {
2024-02-22 21:19:50 +00:00
callMode: CallMode.Direct | CallMode.Group | CallMode.Adhoc;
}
>;
2020-06-04 18:16:19 +00:00
export type RemoteVideoChangeType = ReadonlyDeep<{
conversationId: string;
hasVideo: boolean;
}>;
2020-06-04 18:16:19 +00:00
type RemoteSharingScreenChangeType = ReadonlyDeep<{
conversationId: string;
isSharingScreen: boolean;
}>;
export type RemoveClientType = ReadonlyDeep<{
demuxId: number;
}>;
export type SetLocalAudioType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
enabled: boolean;
}>;
2020-06-04 18:16:19 +00:00
export type SetLocalVideoType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
enabled: boolean;
}>;
2020-06-04 18:16:19 +00:00
export type SetGroupCallVideoRequestType = ReadonlyDeep<{
conversationId: string;
resolutions: Array<GroupCallVideoRequest>;
2022-09-07 15:52:55 +00:00
speakerHeight: number;
}>;
export type StartCallingLobbyType = ReadonlyDeep<{
conversationId: string;
isVideoCall: boolean;
}>;
2024-02-22 21:19:50 +00:00
export type StartCallLinkLobbyType = ReadonlyDeep<{
rootKey: string;
}>;
2024-04-01 19:19:35 +00:00
export type StartCallLinkLobbyByRoomIdType = ReadonlyDeep<{
roomId: string;
}>;
type StartCallLinkLobbyThunkActionType = ReadonlyDeep<
ThunkAction<
void,
RootStateType,
unknown,
StartCallLinkLobbyActionType | ShowErrorModalActionType
>
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallingLobbyPayloadType =
2020-11-13 19:57:55 +00:00
| {
callMode: CallMode.Direct;
conversationId: string;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
}
| {
callMode: CallMode.Group;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
2021-09-02 22:34:38 +00:00
isConversationTooBigToRing: 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
};
2024-02-22 21:19:50 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallLinkLobbyPayloadType = {
callMode: CallMode.Adhoc;
conversationId: string;
connectionState: GroupCallConnectionState;
joinState: GroupCallJoinState;
hasLocalAudio: boolean;
hasLocalVideo: boolean;
isConversationTooBigToRing: boolean;
peekInfo?: GroupCallPeekInfoType;
remoteParticipants: Array<GroupCallParticipantInfoType>;
callLinkState: CallLinkStateType;
2024-05-17 23:22:51 +00:00
callLinkRoomId: string;
2024-02-22 21:19:50 +00:00
callLinkRootKey: string;
};
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2020-08-27 00:03:42 +00:00
export type SetLocalPreviewType = {
element: React.RefObject<HTMLVideoElement> | undefined;
2020-06-04 18:16:19 +00:00
};
// eslint-disable-next-line local-rules/type-alias-readonlydeep
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,
2024-02-22 21:19:50 +00:00
adhocCalls,
2020-11-13 19:57:55 +00:00
callsByConversation,
2024-02-22 21:19:50 +00:00
}: CallingStateType): undefined | DirectCallStateType | GroupCallStateType => {
if (!activeCallState) {
return;
}
const { callMode, conversationId } = activeCallState;
return callMode === CallMode.Adhoc
? getOwn(adhocCalls, conversationId)
: getOwn(callsByConversation, conversationId);
};
2020-11-13 19:57:55 +00:00
2021-08-20 16:06:15 +00:00
const getGroupCallRingState = (
call: Readonly<undefined | GroupCallStateType>
): GroupCallRingStateType =>
call?.ringId === undefined
? {}
: { ringId: call.ringId, ringerAci: call.ringerAci };
2021-08-20 16:06:15 +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>();
2024-02-22 21:19:50 +00:00
const doGroupCallPeek = ({
conversationId,
callMode,
dispatch,
getState,
}: {
conversationId: string;
callMode: CallMode.Group | CallMode.Adhoc;
dispatch: ThunkDispatch<
RootStateType,
unknown,
PeekGroupCallFulfilledActionType
2024-02-22 21:19:50 +00:00
>;
getState: () => RootStateType;
}) => {
let logId: string;
if (callMode === CallMode.Group) {
const conversation = getOwn(
getState().conversations.conversationLookup,
conversationId
);
if (
!conversation ||
getConversationCallMode(conversation) !== CallMode.Group
) {
return;
}
logId = getConversationIdForLogging(conversation);
} else {
const callLink = getOwn(getState().calling.callLinks, conversationId);
if (!callLink) {
return;
}
logId = `adhoc(${conversationId})`;
}
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 != null &&
2024-02-22 21:19:50 +00:00
isGroupOrAdhocCallState(existingCall) &&
existingCall.connectionState !== GroupCallConnectionState.NotConnected
) {
log.info(
`doGroupCallPeek/groupv2: Not peeking because the connection state is ${existingCall.connectionState}`
);
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.
const { server } = window.textsecure;
if (!server) {
log.error('doGroupCallPeek: no textsecure server');
return;
}
await Promise.all([sleep(1000), waitForOnline()]);
2023-08-09 00:53:06 +00:00
let peekInfo = null;
try {
2024-02-22 21:19:50 +00:00
if (callMode === CallMode.Group) {
peekInfo = await calling.peekGroupCall(conversationId);
} else {
// For adhoc calls, conversationId is actually a roomId.
2024-02-22 21:19:50 +00:00
const rootKey: string | undefined = getOwn(
state.calling.callLinks,
conversationId
)?.rootKey;
peekInfo = await calling.peekCallLinkCall(conversationId, rootKey);
}
} catch (err) {
log.error('Group call peeking failed', Errors.toLogFormat(err));
return;
}
if (!peekInfo) {
return;
}
log.info(`doGroupCallPeek/${logId}: Found ${peekInfo.deviceCount} devices`);
const joinState = isGroupOrAdhocCallState(existingCall)
? existingCall.joinState
: null;
2024-02-22 21:19:50 +00:00
if (callMode === CallMode.Group) {
try {
2024-04-01 19:19:35 +00:00
await calling.updateCallHistoryForGroupCallOnPeek(
2024-02-22 21:19:50 +00:00
conversationId,
joinState,
peekInfo
);
} catch (error) {
log.error(
'doGroupCallPeek/groupv2: Failed to update call history',
Errors.toLogFormat(error)
);
}
dispatch(updateLastMessage(conversationId));
} else if (callMode === CallMode.Adhoc) {
await calling.updateCallHistoryForAdhocCall(
conversationId,
joinState,
peekInfo
);
}
const formattedPeekInfo = calling.formatGroupCallPeekInfoForRedux(peekInfo);
dispatch({
type: PEEK_GROUP_CALL_FULFILLED,
payload: {
2024-02-22 21:19:50 +00:00
callMode,
conversationId,
peekInfo: formattedPeekInfo,
},
});
});
};
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';
const APPROVE_USER = 'calling/APPROVE_USER';
2024-06-29 00:13:20 +00:00
const BLOCK_CLIENT = 'calling/BLOCK_CLIENT';
2020-10-08 01:25:33 +00:00
const CANCEL_CALL = 'calling/CANCEL_CALL';
2021-08-20 16:06:15 +00:00
const CANCEL_INCOMING_GROUP_CALL_RING =
'calling/CANCEL_INCOMING_GROUP_CALL_RING';
const CHANGE_CALL_VIEW = 'calling/CHANGE_CALL_VIEW';
const DENY_USER = 'calling/DENY_USER';
const START_CALLING_LOBBY = 'calling/START_CALLING_LOBBY';
2024-02-22 21:19:50 +00:00
const START_CALL_LINK_LOBBY = 'calling/START_CALL_LINK_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';
2021-08-20 16:06:15 +00:00
const DECLINE_DIRECT_CALL = 'calling/DECLINE_DIRECT_CALL';
const GROUP_CALL_AUDIO_LEVELS_CHANGE = 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
2024-02-22 21:19:50 +00:00
const GROUP_CALL_ENDED = 'calling/GROUP_CALL_ENDED';
2023-12-06 21:52:29 +00:00
const GROUP_CALL_RAISED_HANDS_CHANGE = 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
2020-11-13 19:57:55 +00:00
const GROUP_CALL_STATE_CHANGE = 'calling/GROUP_CALL_STATE_CHANGE';
2023-11-16 19:55:35 +00:00
const GROUP_CALL_REACTIONS_RECEIVED = 'calling/GROUP_CALL_REACTIONS_RECEIVED';
const GROUP_CALL_REACTIONS_EXPIRED = 'calling/GROUP_CALL_REACTIONS_EXPIRED';
2024-04-25 17:09:05 +00:00
const HANDLE_CALL_LINK_UPDATE = 'calling/HANDLE_CALL_LINK_UPDATE';
2020-06-04 18:16:19 +00:00
const HANG_UP = 'calling/HANG_UP';
2021-08-20 16:06:15 +00:00
const INCOMING_DIRECT_CALL = 'calling/INCOMING_DIRECT_CALL';
const INCOMING_GROUP_CALL = 'calling/INCOMING_GROUP_CALL';
2020-06-04 18:16:19 +00:00
const OUTGOING_CALL = 'calling/OUTGOING_CALL';
const PEEK_GROUP_CALL_FULFILLED = 'calling/PEEK_GROUP_CALL_FULFILLED';
2023-12-06 21:52:29 +00:00
const RAISE_HAND_GROUP_CALL = 'calling/RAISE_HAND_GROUP_CALL';
2020-08-27 00:03:42 +00:00
const REFRESH_IO_DEVICES = 'calling/REFRESH_IO_DEVICES';
const REMOTE_SHARING_SCREEN_CHANGE = 'calling/REMOTE_SHARING_SCREEN_CHANGE';
2020-06-04 18:16:19 +00:00
const REMOTE_VIDEO_CHANGE = 'calling/REMOTE_VIDEO_CHANGE';
const REMOVE_CLIENT = 'calling/REMOVE_CLIENT';
const RETURN_TO_ACTIVE_CALL = 'calling/RETURN_TO_ACTIVE_CALL';
2023-11-16 19:55:35 +00:00
const SEND_GROUP_CALL_REACTION = 'calling/SEND_GROUP_CALL_REACTION';
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';
const SET_OUTGOING_RING = 'calling/SET_OUTGOING_RING';
const SET_PRESENTING = 'calling/SET_PRESENTING';
const SET_PRESENTING_SOURCES = 'calling/SET_PRESENTING_SOURCES';
const TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS =
'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS';
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';
const SWITCH_TO_PRESENTATION_VIEW = 'calling/SWITCH_TO_PRESENTATION_VIEW';
const SWITCH_FROM_PRESENTATION_VIEW = 'calling/SWITCH_FROM_PRESENTATION_VIEW';
2020-06-04 18:16:19 +00:00
type AcceptCallPendingActionType = ReadonlyDeep<{
2020-11-04 17:47:50 +00:00
type: 'calling/ACCEPT_CALL_PENDING';
2020-06-04 18:16:19 +00:00
payload: AcceptCallType;
}>;
2020-06-04 18:16:19 +00:00
type ApproveUserActionType = ReadonlyDeep<{
type: 'calling/APPROVE_USER';
}>;
type CancelCallActionType = ReadonlyDeep<{
2020-10-08 01:25:33 +00:00
type: 'calling/CANCEL_CALL';
}>;
2020-10-08 01:25:33 +00:00
type CancelIncomingGroupCallRingActionType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
type: 'calling/CANCEL_INCOMING_GROUP_CALL_RING';
payload: CancelIncomingGroupCallRingType;
}>;
2021-08-20 16:06:15 +00:00
type DenyUserActionType = ReadonlyDeep<{
type: 'calling/DENY_USER';
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallingLobbyActionType = {
type: 'calling/START_CALLING_LOBBY';
payload: StartCallingLobbyPayloadType;
2020-10-08 01:25:33 +00:00
};
2024-02-22 21:19:50 +00:00
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type StartCallLinkLobbyActionType = {
type: 'calling/START_CALL_LINK_LOBBY';
payload: StartCallLinkLobbyPayloadType;
};
type CallStateChangeFulfilledActionType = ReadonlyDeep<{
type: 'calling/CALL_STATE_CHANGE_FULFILLED';
2020-06-04 18:16:19 +00:00
payload: CallStateChangeType;
}>;
2020-06-04 18:16:19 +00:00
type ChangeIODeviceFulfilledActionType = ReadonlyDeep<{
2020-08-27 00:03:42 +00:00
type: 'calling/CHANGE_IO_DEVICE_FULFILLED';
payload: ChangeIODevicePayloadType;
}>;
2020-08-27 00:03:42 +00:00
type CloseNeedPermissionScreenActionType = ReadonlyDeep<{
2020-10-01 19:09:15 +00:00
type: 'calling/CLOSE_NEED_PERMISSION_SCREEN';
payload: null;
}>;
2020-10-01 19:09:15 +00:00
type DeclineCallActionType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
type: 'calling/DECLINE_DIRECT_CALL';
2020-06-04 18:16:19 +00:00
payload: DeclineCallType;
}>;
2020-06-04 18:16:19 +00:00
type GroupCallAudioLevelsChangeActionPayloadType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
conversationId: string;
localAudioLevel: number;
remoteDeviceStates: ReadonlyArray<{ audioLevel: number; demuxId: number }>;
}>;
type GroupCallAudioLevelsChangeActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_AUDIO_LEVELS_CHANGE';
payload: GroupCallAudioLevelsChangeActionPayloadType;
}>;
2024-02-22 21:19:50 +00:00
type GroupCallEndedActionPayloadType = ReadonlyDeep<{
conversationId: string;
endedReason: GroupCallEndReason;
}>;
export type GroupCallEndedActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_ENDED';
payload: GroupCallEndedActionPayloadType;
}>;
2023-12-06 21:52:29 +00:00
type GroupCallRaisedHandsChangeActionPayloadType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2023-12-06 21:52:29 +00:00
conversationId: string;
raisedHands: ReadonlyArray<number>;
}>;
type GroupCallRaisedHandsChangeActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_RAISED_HANDS_CHANGE';
payload: GroupCallRaisedHandsChangeActionPayloadType;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
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
};
2023-11-16 19:55:35 +00:00
type GroupCallReactionsReceivedActionPayloadType = ReadonlyDeep<{
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2023-11-16 19:55:35 +00:00
conversationId: string;
reactions: Array<CallReaction>;
timestamp: number;
}>;
type GroupCallReactionsExpiredActionPayloadType = ReadonlyDeep<{
conversationId: string;
timestamp: number;
}>;
export type GroupCallReactionsReceivedActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_REACTIONS_RECEIVED';
payload: GroupCallReactionsReceivedActionPayloadType;
}>;
type GroupCallReactionsExpiredActionType = ReadonlyDeep<{
type: 'calling/GROUP_CALL_REACTIONS_EXPIRED';
payload: GroupCallReactionsExpiredActionPayloadType;
}>;
2024-04-25 17:09:05 +00:00
type HandleCallLinkUpdateActionType = ReadonlyDeep<{
type: 'calling/HANDLE_CALL_LINK_UPDATE';
payload: HandleCallLinkUpdateActionPayloadType;
}>;
type HangUpActionType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
type: 'calling/HANG_UP';
payload: HangUpActionPayloadType;
}>;
2020-06-04 18:16:19 +00:00
type IncomingDirectCallActionType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
type: 'calling/INCOMING_DIRECT_CALL';
payload: IncomingDirectCallType;
}>;
2021-08-20 16:06:15 +00:00
type IncomingGroupCallActionType = ReadonlyDeep<{
2021-08-20 16:06:15 +00:00
type: 'calling/INCOMING_GROUP_CALL';
payload: IncomingGroupCallType;
}>;
2020-06-04 18:16:19 +00:00
2023-12-06 21:52:29 +00:00
type SendGroupCallRaiseHandActionType = ReadonlyDeep<{
type: 'calling/RAISE_HAND_GROUP_CALL';
payload: SendGroupCallRaiseHandType;
}>;
2023-11-16 19:55:35 +00:00
export type SendGroupCallReactionActionType = ReadonlyDeep<{
type: 'calling/SEND_GROUP_CALL_REACTION';
payload: SendGroupCallReactionLocalCopyType;
}>;
type OutgoingCallActionType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
type: 'calling/OUTGOING_CALL';
2020-11-13 19:57:55 +00:00
payload: StartDirectCallType;
}>;
2020-06-04 18:16:19 +00:00
export type PeekGroupCallFulfilledActionType = ReadonlyDeep<{
type: 'calling/PEEK_GROUP_CALL_FULFILLED';
2020-11-20 17:19:28 +00:00
payload: {
2024-02-22 21:19:50 +00:00
callMode: CallMode;
2020-11-20 17:19:28 +00:00
conversationId: string;
peekInfo: GroupCallPeekInfoType;
};
}>;
2020-11-20 17:19:28 +00:00
export type PendingUserActionPayloadType = ReadonlyDeep<{
serviceId: ServiceIdString | undefined;
}>;
export type BatchUserActionPayloadType = ReadonlyDeep<{
action: 'approve' | 'deny';
serviceIds: Array<ServiceIdString>;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2020-08-27 00:03:42 +00:00
type RefreshIODevicesActionType = {
type: 'calling/REFRESH_IO_DEVICES';
payload: MediaDeviceSettings;
};
type RemoteSharingScreenChangeActionType = ReadonlyDeep<{
type: 'calling/REMOTE_SHARING_SCREEN_CHANGE';
payload: RemoteSharingScreenChangeType;
}>;
type RemoteVideoChangeActionType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
type: 'calling/REMOTE_VIDEO_CHANGE';
payload: RemoteVideoChangeType;
}>;
2020-06-04 18:16:19 +00:00
type RemoveClientActionType = ReadonlyDeep<{
type: 'calling/REMOVE_CLIENT';
}>;
2024-06-29 00:13:20 +00:00
type BlockClientActionType = ReadonlyDeep<{
type: 'calling/BLOCK_CLIENT';
}>;
type ReturnToActiveCallActionType = ReadonlyDeep<{
type: 'calling/RETURN_TO_ACTIVE_CALL';
}>;
type SetLocalAudioActionType = ReadonlyDeep<{
type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
2020-06-04 18:16:19 +00:00
payload: SetLocalAudioType;
}>;
2020-06-04 18:16:19 +00:00
type SetLocalVideoFulfilledActionType = ReadonlyDeep<{
2020-06-04 18:16:19 +00:00
type: 'calling/SET_LOCAL_VIDEO_FULFILLED';
payload: SetLocalVideoType;
}>;
2020-06-04 18:16:19 +00:00
type SetPresentingFulfilledActionType = ReadonlyDeep<{
type: 'calling/SET_PRESENTING';
payload?: PresentedSource;
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
type SetPresentingSourcesActionType = {
type: 'calling/SET_PRESENTING_SOURCES';
payload: Array<PresentableSource>;
};
type SetOutgoingRingActionType = ReadonlyDeep<{
type: 'calling/SET_OUTGOING_RING';
payload: boolean;
}>;
type StartDirectCallActionType = ReadonlyDeep<{
2020-11-13 19:57:55 +00:00
type: 'calling/START_DIRECT_CALL';
payload: StartDirectCallType;
}>;
2020-10-08 01:25:33 +00:00
type ToggleNeedsScreenRecordingPermissionsActionType = ReadonlyDeep<{
type: 'calling/TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS';
}>;
type ToggleParticipantsActionType = ReadonlyDeep<{
2020-10-08 01:25:33 +00:00
type: 'calling/TOGGLE_PARTICIPANTS';
}>;
2020-10-08 01:25:33 +00:00
type TogglePipActionType = ReadonlyDeep<{
2020-10-01 00:43:05 +00:00
type: 'calling/TOGGLE_PIP';
}>;
2020-10-01 00:43:05 +00:00
type ToggleSettingsActionType = ReadonlyDeep<{
2020-08-27 00:03:42 +00:00
type: 'calling/TOGGLE_SETTINGS';
}>;
2020-08-27 00:03:42 +00:00
type ChangeCallViewActionType = ReadonlyDeep<{
type: 'calling/CHANGE_CALL_VIEW';
viewMode: CallViewMode;
}>;
2021-01-08 22:57:54 +00:00
type SwitchToPresentationViewActionType = ReadonlyDeep<{
type: 'calling/SWITCH_TO_PRESENTATION_VIEW';
}>;
type SwitchFromPresentationViewActionType = ReadonlyDeep<{
type: 'calling/SWITCH_FROM_PRESENTATION_VIEW';
}>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep
2020-06-04 18:16:19 +00:00
export type CallingActionType =
| ApproveUserActionType
2020-11-04 17:47:50 +00:00
| AcceptCallPendingActionType
2020-10-08 01:25:33 +00:00
| CancelCallActionType
2021-08-20 16:06:15 +00:00
| CancelIncomingGroupCallRingActionType
| ChangeCallViewActionType
| DenyUserActionType
| StartCallingLobbyActionType
2024-02-22 21:19:50 +00:00
| StartCallLinkLobbyActionType
| CallStateChangeFulfilledActionType
2020-08-27 00:03:42 +00:00
| ChangeIODeviceFulfilledActionType
2020-10-01 19:09:15 +00:00
| CloseNeedPermissionScreenActionType
2021-09-02 22:34:38 +00:00
| ConversationChangedActionType
| ConversationRemovedActionType
2020-06-04 18:16:19 +00:00
| DeclineCallActionType
| GroupCallAudioLevelsChangeActionType
2024-02-22 21:19:50 +00:00
| GroupCallEndedActionType
2023-12-06 21:52:29 +00:00
| GroupCallRaisedHandsChangeActionType
2020-11-13 19:57:55 +00:00
| GroupCallStateChangeActionType
2023-11-16 19:55:35 +00:00
| GroupCallReactionsReceivedActionType
| GroupCallReactionsExpiredActionType
2024-04-25 17:09:05 +00:00
| HandleCallLinkUpdateActionType
2020-06-04 18:16:19 +00:00
| HangUpActionType
2021-08-20 16:06:15 +00:00
| IncomingDirectCallActionType
| IncomingGroupCallActionType
2020-06-04 18:16:19 +00:00
| OutgoingCallActionType
| PeekGroupCallFulfilledActionType
2020-08-27 00:03:42 +00:00
| RefreshIODevicesActionType
| RemoteSharingScreenChangeActionType
2020-06-04 18:16:19 +00:00
| RemoteVideoChangeActionType
| RemoveClientActionType
| ReturnToActiveCallActionType
2023-11-16 19:55:35 +00:00
| SendGroupCallReactionActionType
2020-06-04 18:16:19 +00:00
| SetLocalAudioActionType
2020-08-27 00:03:42 +00:00
| SetLocalVideoFulfilledActionType
| SetPresentingSourcesActionType
| SetOutgoingRingActionType
2020-11-13 19:57:55 +00:00
| StartDirectCallActionType
| ToggleNeedsScreenRecordingPermissionsActionType
2020-10-08 01:25:33 +00:00
| ToggleParticipantsActionType
2020-10-01 00:43:05 +00:00
| TogglePipActionType
| SetPresentingFulfilledActionType
2021-01-08 22:57:54 +00:00
| ToggleSettingsActionType
| SwitchToPresentationViewActionType
| SwitchFromPresentationViewActionType;
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> {
2021-08-20 16:06:15 +00:00
return async (dispatch, getState) => {
const { conversationId, asVideoCall } = payload;
2024-02-22 21:19:50 +00:00
const callingState = getState().calling;
const call = getOwn(callingState.callsByConversation, conversationId);
2021-08-20 16:06:15 +00:00
if (!call) {
log.error('Trying to accept a non-existent call');
2021-08-20 16:06:15 +00:00
return;
}
saveDraftRecordingIfNeeded()(dispatch, getState, undefined);
2021-08-20 16:06:15 +00:00
switch (call.callMode) {
case CallMode.Direct:
await calling.acceptDirectCall(conversationId, asVideoCall);
break;
case CallMode.Group:
await calling.joinGroupCall(conversationId, true, asVideoCall, false);
2021-08-20 16:06:15 +00:00
break;
2024-02-22 21:19:50 +00:00
case CallMode.Adhoc:
log.error('Failed to accept adhoc call, this should never happen.');
break;
2021-08-20 16:06:15 +00:00
default:
throw missingCaseError(call);
}
2020-11-04 17:47:50 +00:00
dispatch({
type: ACCEPT_CALL_PENDING,
payload,
});
2020-06-04 18:16:19 +00:00
};
}
function approveUser(
payload: PendingUserActionPayloadType
): ThunkAction<void, RootStateType, unknown, ApproveUserActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
'approveUser: Trying to approve pending user without active group or adhoc call'
);
return;
}
if (!isAciString(payload.serviceId)) {
log.warn(
'approveUser: Trying to approve pending user without valid aci serviceid'
);
return;
}
calling.approveUser(activeCall.conversationId, payload.serviceId);
dispatch({ type: APPROVE_USER });
};
}
function denyUser(
payload: PendingUserActionPayloadType
): ThunkAction<void, RootStateType, unknown, DenyUserActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
'approveUser: Trying to approve pending user without active group or adhoc call'
);
return;
}
if (!isAciString(payload.serviceId)) {
log.warn(
'approveUser: Trying to approve pending user without valid aci serviceid'
);
return;
}
calling.denyUser(activeCall.conversationId, payload.serviceId);
dispatch({ type: DENY_USER });
};
}
function batchUserAction(
payload: BatchUserActionPayloadType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
'batchUserAction: Trying to do pending user without active group or adhoc call'
);
return;
}
const { action, serviceIds } = payload;
let actionFn;
if (action === 'approve') {
actionFn = calling.approveUser;
} else if (action === 'deny') {
actionFn = calling.denyUser;
} else {
throw missingCaseError(action);
}
let count = 0;
for (const serviceId of serviceIds) {
if (!isAciString(serviceId)) {
log.warn(
'batchUserAction: Trying to do user action without valid aci serviceid'
);
continue;
}
actionFn.call(calling, activeCall.conversationId, serviceId);
count += 1;
}
if (count > 0 && action === 'approve') {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.AddedUsersToCall,
parameters: { count },
},
});
}
};
}
function removeClient(
payload: RemoveClientType
): ThunkAction<void, RootStateType, unknown, RemoveClientActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
2024-06-29 00:13:20 +00:00
'removeClient: Trying to remove client without active group or adhoc call'
);
return;
}
calling.removeClient(activeCall.conversationId, payload.demuxId);
dispatch({ type: REMOVE_CLIENT });
};
}
2024-06-29 00:13:20 +00:00
function blockClient(
payload: RemoveClientType
): ThunkAction<void, RootStateType, unknown, BlockClientActionType> {
return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling);
if (!activeCall || !isGroupOrAdhocCallMode(activeCall.callMode)) {
log.warn(
'blockClient: Trying to block client without active group or adhoc call'
);
return;
}
calling.blockClient(activeCall.conversationId, payload.demuxId);
dispatch({ type: BLOCK_CLIENT });
};
}
2020-06-04 18:16:19 +00:00
function callStateChange(
payload: CallStateChangeType
2020-11-04 17:47:50 +00:00
): ThunkAction<
void,
RootStateType,
unknown,
CallStateChangeFulfilledActionType
> {
return async dispatch => {
2023-08-09 00:53:06 +00:00
const { callState, acceptedTime, callEndedReason } = payload;
2023-01-10 00:52:01 +00:00
const wasAccepted = acceptedTime != null;
const isEnded = callState === CallState.Ended && callEndedReason != null;
const isLocalHangup = callEndedReason === CallEndedReason.LocalHangup;
const isRemoteHangup = callEndedReason === CallEndedReason.RemoteHangup;
// Play the hangup noise if:
if (
// 1. I hungup (or declined)
(isEnded && isLocalHangup) ||
// 2. I answered and then the call ended
(isEnded && wasAccepted) ||
// 3. I called and they declined
(isEnded && !wasAccepted && isRemoteHangup)
) {
await callingTones.playEndCall();
}
2020-11-04 17:47:50 +00:00
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
};
}
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,
};
}
2021-08-20 16:06:15 +00:00
function cancelIncomingGroupCallRing(
payload: CancelIncomingGroupCallRingType
): CancelIncomingGroupCallRingActionType {
2020-06-04 18:16:19 +00:00
return {
2021-08-20 16:06:15 +00:00
type: CANCEL_INCOMING_GROUP_CALL_RING,
2020-06-04 18:16:19 +00:00
payload,
};
}
2021-08-20 16:06:15 +00:00
function declineCall(
payload: DeclineCallType
): ThunkAction<
void,
RootStateType,
unknown,
CancelIncomingGroupCallRingActionType | DeclineCallActionType
> {
return (dispatch, getState) => {
const { conversationId } = payload;
const call = getOwn(getState().calling.callsByConversation, conversationId);
if (!call) {
log.error('Trying to decline a non-existent call');
2021-08-20 16:06:15 +00:00
return;
}
switch (call.callMode) {
case CallMode.Direct:
calling.declineDirectCall(conversationId);
dispatch({
type: DECLINE_DIRECT_CALL,
payload,
});
break;
case CallMode.Group: {
const { ringId } = call;
if (ringId === undefined) {
log.error('Trying to decline a group call without a ring ID');
2021-08-20 16:06:15 +00:00
} else {
calling.declineGroupCall(conversationId, ringId);
dispatch({
type: CANCEL_INCOMING_GROUP_CALL_RING,
payload: { conversationId, ringId },
});
}
break;
}
2024-02-22 21:19:50 +00:00
case CallMode.Adhoc:
log.error(
'Cannot decline an adhoc call because adhoc calls should never be incoming.'
);
break;
2021-08-20 16:06:15 +00:00
default:
throw missingCaseError(call);
}
};
}
function getPresentingSources(): ThunkAction<
void,
RootStateType,
unknown,
| SetPresentingSourcesActionType
| ToggleNeedsScreenRecordingPermissionsActionType
> {
return async (dispatch, getState) => {
// We check if the user has permissions first before calling desktopCapturer
// Next we call getPresentingSources so that one gets the prompt for permissions,
// if necessary.
// Finally, we have the if statement which shows the modal, if needed.
// It is in this exact order so that during first-time-use one will be
// prompted for permissions and if they so happen to deny we can still
// capture that state correctly.
const platform = getPlatform(getState());
const needsPermission =
platform === 'darwin' && !hasScreenCapturePermission();
const sources = await calling.getPresentingSources();
if (needsPermission) {
dispatch({
type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS,
});
return;
}
dispatch({
type: SET_PRESENTING_SOURCES,
payload: sources,
});
};
}
function groupCallAudioLevelsChange(
payload: GroupCallAudioLevelsChangeActionPayloadType
): GroupCallAudioLevelsChangeActionType {
return { type: GROUP_CALL_AUDIO_LEVELS_CHANGE, payload };
}
2024-02-22 21:19:50 +00:00
function groupCallEnded(
payload: GroupCallEndedActionPayloadType
): ThunkAction<
void,
RootStateType,
unknown,
GroupCallEndedActionType | ShowErrorModalActionType
> {
return (dispatch, getState) => {
const { endedReason } = payload;
if (endedReason === GroupCallEndReason.DeniedRequestToJoinCall) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__join-request-denied-title'),
description: i18n('icu:calling__join-request-denied'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
if (endedReason === GroupCallEndReason.RemovedFromCall) {
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__removed-from-call-title'),
description: i18n('icu:calling__removed-from-call'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
dispatch({ type: GROUP_CALL_ENDED, payload });
};
}
2023-11-16 19:55:35 +00:00
function receiveGroupCallReactions(
payload: GroupCallReactionsReceivedArgumentType
): ThunkAction<
void,
RootStateType,
unknown,
GroupCallReactionsReceivedActionType | GroupCallReactionsExpiredActionType
> {
return async dispatch => {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId } = payload;
2023-11-16 19:55:35 +00:00
const timestamp = Date.now();
dispatch({
type: GROUP_CALL_REACTIONS_RECEIVED,
2024-02-22 21:19:50 +00:00
payload: { ...payload, callMode, timestamp },
2023-11-16 19:55:35 +00:00
});
await sleep(CALLING_REACTIONS_LIFETIME);
dispatch({
type: GROUP_CALL_REACTIONS_EXPIRED,
payload: { conversationId, timestamp },
});
};
}
2023-12-06 21:52:29 +00:00
function groupCallRaisedHandsChange(
payload: GroupCallRaisedHandsChangeActionPayloadType
): ThunkAction<
void,
RootStateType,
unknown,
GroupCallRaisedHandsChangeActionType
> {
return async (dispatch, getState) => {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId, raisedHands } = payload;
2024-02-22 21:19:50 +00:00
const existingCall = getGroupCall(
conversationId,
getState().calling,
callMode
);
const isFirstHandRaised =
existingCall &&
!existingCall.raisedHands?.length &&
raisedHands.length > 0;
if (isFirstHandRaised) {
drop(callingTones.handRaised());
}
dispatch({ type: GROUP_CALL_RAISED_HANDS_CHANGE, payload });
};
2023-12-06 21:52:29 +00:00
}
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 async (dispatch, getState) => {
let didSomeoneStartPresenting: boolean;
const activeCall = getActiveCall(getState().calling);
2024-02-22 21:19:50 +00:00
if (isGroupOrAdhocCallState(activeCall)) {
const wasSomeonePresenting = activeCall.remoteParticipants.some(
participant => participant.presenting
);
const isSomeonePresenting = payload.remoteParticipants.some(
participant => participant.presenting
);
didSomeoneStartPresenting = !wasSomeonePresenting && isSomeonePresenting;
} else {
didSomeoneStartPresenting = false;
}
const { ourAci } = getState().user;
strictAssert(ourAci, 'groupCallStateChange failed to fetch our ACI');
2020-11-20 17:19:28 +00:00
dispatch({
type: GROUP_CALL_STATE_CHANGE,
payload: {
...payload,
ourAci,
2020-11-20 17:19:28 +00:00
},
});
if (didSomeoneStartPresenting) {
void callingTones.someonePresenting();
}
2020-11-13 19:57:55 +00:00
};
}
2024-04-25 17:09:05 +00:00
// From sync messages, to notify us that another device joined or changed a call link.
function handleCallLinkUpdate(
payload: HandleCallLinkUpdateType
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const { rootKey, adminKey } = payload;
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
const logId = `handleCallLinkUpdate(${roomId})`;
const readResult = await calling.readCallLink({
callLinkRootKey,
});
// Only give up when server confirms the call link is gone. If we fail to fetch
// state due to unexpected errors, continue to save rootKey and adminKey.
if (readResult.errorStatusCode === 404) {
log.info(`${logId}: Call link not found, ignoring`);
return;
}
const { callLinkState: freshCallLinkState } = readResult;
const existingCallLink = await dataInterface.getCallLinkByRoomId(roomId);
const existingCallLinkState = pick(existingCallLink, [
'name',
'restrictions',
'expiration',
'revoked',
]);
2024-05-17 23:22:51 +00:00
const callLink: CallLinkType = {
2024-04-25 17:09:05 +00:00
...CALL_LINK_DEFAULT_STATE,
...existingCallLinkState,
...freshCallLinkState,
2024-05-17 23:22:51 +00:00
roomId,
2024-04-25 17:09:05 +00:00
rootKey,
adminKey,
};
if (existingCallLink) {
if (adminKey && adminKey !== existingCallLink.adminKey) {
await dataInterface.updateCallLinkAdminKeyByRoomId(roomId, adminKey);
log.info(`${logId}: Updated existing call link with new adminKey`);
}
if (freshCallLinkState) {
await dataInterface.updateCallLinkState(roomId, freshCallLinkState);
log.info(`${logId}: Updated existing call link state`);
}
} else {
2024-05-17 23:22:51 +00:00
await dataInterface.insertCallLink(callLink);
2024-04-25 17:09:05 +00:00
log.info(`${logId}: Saved new call link`);
}
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
2024-05-17 23:22:51 +00:00
payload: { callLink },
2024-04-25 17:09:05 +00:00
});
};
}
/**
* When starting a lobby and there's an active call, if we're already in call then
* focus it (toggle pip), otherwise show an error.
* @returns {boolean} `true` if there was an active call and we handled it.
*/
function handleActiveCallOnStartLobby({
conversationId,
state,
dispatch,
}: {
conversationId: string;
state: RootStateType;
dispatch: ThunkDispatch<
RootStateType,
unknown,
ShowErrorModalActionType | TogglePipActionType
>;
}): boolean {
const { activeCallState } = state.calling;
if (!activeCallState) {
return false;
}
if (activeCallState.conversationId === conversationId) {
dispatch({
type: TOGGLE_PIP,
});
} else {
const i18n = getIntl(state);
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__dialog-already-in-call'),
buttonVariant: ButtonVariant.Primary,
},
});
}
return true;
}
2022-08-16 23:52:09 +00:00
function hangUpActiveCall(
reason: string
): ThunkAction<void, RootStateType, unknown, HangUpActionType> {
return async (dispatch, getState) => {
2022-01-24 20:32:09 +00:00
const state = getState();
const activeCall = getActiveCall(state.calling);
if (!activeCall) {
return;
}
const { conversationId } = activeCall;
2022-08-16 23:52:09 +00:00
calling.hangup(conversationId, reason);
2022-01-24 20:32:09 +00:00
dispatch({
type: HANG_UP,
payload: {
conversationId,
},
});
2024-02-22 21:19:50 +00:00
if (isGroupOrAdhocCallState(activeCall)) {
// We want to give the group call time to disconnect.
await sleep(1000);
2024-02-22 21:19:50 +00:00
doGroupCallPeek({
conversationId,
callMode: activeCall.callMode,
dispatch,
getState,
});
}
2022-01-24 20:32:09 +00:00
};
}
2023-12-06 21:52:29 +00:00
function sendGroupCallRaiseHand(
payload: SendGroupCallRaiseHandType
): ThunkAction<void, RootStateType, unknown, SendGroupCallRaiseHandActionType> {
return dispatch => {
calling.sendGroupCallRaiseHand(payload.conversationId, payload.raise);
dispatch({
type: RAISE_HAND_GROUP_CALL,
payload,
});
};
}
2023-11-16 19:55:35 +00:00
function sendGroupCallReaction(
payload: SendGroupCallReactionType
): ThunkAction<
void,
RootStateType,
unknown,
SendGroupCallReactionActionType | GroupCallReactionsExpiredActionType
> {
return async dispatch => {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId } = payload;
2023-11-16 19:55:35 +00:00
const timestamp = Date.now();
calling.sendGroupCallReaction(payload.conversationId, payload.value);
dispatch({
type: SEND_GROUP_CALL_REACTION,
2024-02-22 21:19:50 +00:00
payload: { ...payload, callMode, timestamp },
2023-11-16 19:55:35 +00:00
});
await sleep(CALLING_REACTIONS_LIFETIME);
dispatch({
type: GROUP_CALL_REACTIONS_EXPIRED,
payload: { conversationId, timestamp },
});
};
}
2021-08-20 16:06:15 +00:00
function receiveIncomingDirectCall(
payload: IncomingDirectCallType
2023-07-28 00:29:10 +00:00
): ThunkAction<void, RootStateType, unknown, IncomingDirectCallActionType> {
return (dispatch, getState) => {
const callState = getState().calling;
if (
callState.activeCallState &&
callState.activeCallState.conversationId === payload.conversationId
) {
calling.stopCallingLobby();
}
dispatch({
type: INCOMING_DIRECT_CALL,
payload,
});
2021-08-20 16:06:15 +00:00
};
}
function receiveIncomingGroupCall(
payload: IncomingGroupCallType
): IncomingGroupCallActionType {
2020-06-04 18:16:19 +00:00
return {
2021-08-20 16:06:15 +00:00
type: INCOMING_GROUP_CALL,
2020-06-04 18:16:19 +00:00
payload,
};
}
function openSystemPreferencesAction(): ThunkAction<
void,
RootStateType,
unknown,
never
> {
return () => {
void openSystemPreferences();
};
}
2020-11-13 19:57:55 +00:00
function outgoingCall(payload: StartDirectCallType): OutgoingCallActionType {
2020-06-04 18:16:19 +00:00
return {
type: OUTGOING_CALL,
payload,
};
}
2024-04-25 17:09:05 +00:00
function joinedAdhocCall(
roomId: string
): ThunkAction<void, RootStateType, unknown, never> {
return async (_dispatch, getState) => {
const state = getState();
const callLink = getOwn(state.calling.callLinks, roomId);
if (!callLink) {
log.warn(`joinedAdhocCall(${roomId}): call link not found`);
return;
}
drop(sendCallLinkUpdateSync(callLink));
};
}
function peekGroupCallForTheFirstTime(
conversationId: string
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
return (dispatch, getState) => {
const call = getOwn(getState().calling.callsByConversation, conversationId);
const shouldPeek =
2024-02-22 21:19:50 +00:00
!call || (isGroupOrAdhocCallState(call) && !call.peekInfo);
const callMode = call?.callMode ?? CallMode.Group;
if (callMode === CallMode.Direct) {
return;
}
if (shouldPeek) {
2024-02-22 21:19:50 +00:00
doGroupCallPeek({
conversationId,
callMode,
dispatch,
getState,
});
}
};
}
function peekGroupCallIfItHasMembers(
conversationId: string
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
return (dispatch, getState) => {
const call = getOwn(getState().calling.callsByConversation, conversationId);
const shouldPeek =
call &&
2024-02-22 21:19:50 +00:00
isGroupOrAdhocCallState(call) &&
call.joinState === GroupCallJoinState.NotJoined &&
call.peekInfo &&
call.peekInfo.deviceCount > 0;
if (shouldPeek) {
2024-02-22 21:19:50 +00:00
doGroupCallPeek({
conversationId,
callMode: call.callMode,
dispatch,
getState,
});
}
};
}
2020-11-20 17:19:28 +00:00
function peekNotConnectedGroupCall(
payload: PeekNotConnectedGroupCallType
): ThunkAction<void, RootStateType, unknown, PeekGroupCallFulfilledActionType> {
2020-11-20 17:19:28 +00:00
return (dispatch, getState) => {
const { callMode, conversationId } = payload;
2024-02-22 21:19:50 +00:00
doGroupCallPeek({
conversationId,
callMode,
2024-02-22 21:19:50 +00:00
dispatch,
getState,
});
2020-11-20 17:19:28 +00:00
};
}
2020-08-27 00:03:42 +00:00
function refreshIODevices(
payload: MediaDeviceSettings
): RefreshIODevicesActionType {
return {
type: REFRESH_IO_DEVICES,
payload,
};
}
function remoteSharingScreenChange(
payload: RemoteSharingScreenChangeType
): RemoteSharingScreenChangeActionType {
return {
type: REMOTE_SHARING_SCREEN_CHANGE,
payload,
};
}
2020-06-04 18:16:19 +00:00
function remoteVideoChange(
payload: RemoteVideoChangeType
): RemoteVideoChangeActionType {
return {
type: REMOTE_VIDEO_CHANGE,
payload,
};
}
function returnToActiveCall(): ReturnToActiveCallActionType {
return {
type: RETURN_TO_ACTIVE_CALL,
};
}
function setIsCallActive(
isCallActive: boolean
): ThunkAction<void, RootStateType, unknown, never> {
return () => {
window.SignalContext.setIsCallActive(isCallActive);
};
}
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) {
log.warn('Trying to set local audio when no call is active');
2020-11-13 19:57:55 +00:00
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) {
log.warn('Trying to set local video when no call is active');
2020-11-13 19:57:55 +00:00
return;
}
2020-11-04 17:47:50 +00:00
let enabled: boolean;
if (await requestCameraPermissions()) {
2020-11-13 19:57:55 +00:00
if (
2024-02-22 21:19:50 +00:00
isGroupOrAdhocCallState(activeCall) ||
2020-11-13 19:57:55 +00:00
(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.disableLocalVideo();
2020-11-04 17:47:50 +00:00
}
({ 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,
2022-09-07 15:52:55 +00:00
})),
payload.speakerHeight
);
};
}
function setPresenting(
sourceToPresent?: PresentedSource
): ThunkAction<void, RootStateType, unknown, SetPresentingFulfilledActionType> {
return async (dispatch, getState) => {
const callingState = getState().calling;
const { activeCallState } = callingState;
const activeCall = getActiveCall(callingState);
if (!activeCall || !activeCallState) {
log.warn('Trying to present when no call is active');
return;
}
2023-08-01 16:06:29 +00:00
await calling.setPresenting(
activeCall.conversationId,
activeCallState.hasLocalVideo,
sourceToPresent
);
dispatch({
type: SET_PRESENTING,
payload: sourceToPresent,
});
if (sourceToPresent) {
await callingTones.someonePresenting();
}
};
}
function setOutgoingRing(payload: boolean): SetOutgoingRingActionType {
return {
type: SET_OUTGOING_RING,
payload,
};
}
function onOutgoingVideoCallInConversation(
conversationId: string
): ThunkAction<
void,
RootStateType,
unknown,
StartCallingLobbyActionType | ShowToastActionType
> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`onOutgoingVideoCallInConversation: No conversation found for conversation ${conversationId}`
);
}
log.info('onOutgoingVideoCallInConversation: about to start a video call');
const call = getOwn(getState().calling.callsByConversation, conversationId);
// Technically not necessary, but isAnybodyElseInGroupCall requires it
const ourAci = window.storage.user.getCheckedAci();
const isOngoingGroupCall =
call &&
ourAci &&
2024-02-22 21:19:50 +00:00
isGroupOrAdhocCallState(call) &&
call.peekInfo &&
isAnybodyElseInGroupCall(call.peekInfo, ourAci);
// If it's a group call on an announcementsOnly group, only allow join if the call
// has already been started (presumably by the admin)
if (conversation.get('announcementsOnly') && !conversation.areWeAdmin()) {
if (!isOngoingGroupCall) {
dispatch({
type: SHOW_TOAST,
payload: {
toastType: ToastType.CannotStartGroupCall,
},
});
return;
}
}
const source = isOngoingGroupCall
? SafetyNumberChangeSource.JoinCall
: SafetyNumberChangeSource.InitiateCall;
if (await isCallSafe(conversation.attributes, source)) {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "safe". Making call'
);
2023-08-09 00:53:06 +00:00
dispatch(
startCallingLobby({
conversationId,
isVideoCall: true,
})
);
log.info('onOutgoingVideoCallInConversation: started the call');
} else {
log.info(
'onOutgoingVideoCallInConversation: call is deemed "unsafe". Stopping'
);
}
};
}
function onOutgoingAudioCallInConversation(
conversationId: string
): ThunkAction<void, RootStateType, unknown, StartCallingLobbyActionType> {
return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
throw new Error(
`onOutgoingAudioCallInConversation: No conversation found for conversation ${conversationId}`
);
}
if (!isDirectConversation(conversation.attributes)) {
throw new Error(
`onOutgoingAudioCallInConversation: Conversation ${conversation.idForLogging()} is not 1:1`
);
}
// Because audio calls are currently restricted to 1:1 conversations, this will always
// be a new call we are initiating.
const source = SafetyNumberChangeSource.InitiateCall;
log.info('onOutgoingAudioCallInConversation: about to start an audio call');
if (await isCallSafe(conversation.attributes, source)) {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "safe". Making call'
);
startCallingLobby({
conversationId,
isVideoCall: false,
})(dispatch, getState, undefined);
log.info('onOutgoingAudioCallInConversation: started the call');
} else {
log.info(
'onOutgoingAudioCallInConversation: call is deemed "unsafe". Stopping'
);
}
};
}
2024-06-10 15:23:43 +00:00
function createCallLink(
onCreated: (roomId: string) => void
): ThunkAction<
void,
RootStateType,
unknown,
CallHistoryAdd | HandleCallLinkUpdateActionType
> {
return async dispatch => {
strictAssert(isCallLinksCreateEnabled(), 'Call links creation is disabled');
const callLink = await calling.createCallLink();
const callHistory: CallHistoryDetails = {
callId: generateUuid(),
peerId: callLink.roomId,
ringerId: null,
mode: CallMode.Adhoc,
type: CallType.Adhoc,
direction: CallDirection.Incoming,
timestamp: Date.now(),
status: AdhocCallStatus.Pending,
};
await Promise.all([
dataInterface.insertCallLink(callLink),
dataInterface.saveCallHistory(callHistory),
]);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
dispatch(addCallHistory(callHistory));
// Call after dispatching the action to ensure the call link is in the store
onCreated(callLink.roomId);
};
}
function updateCallLinkName(
roomId: string,
name: string
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const prevCallLink = await dataInterface.getCallLinkByRoomId(roomId);
strictAssert(
prevCallLink,
`updateCallLinkName(${roomId}): call link not found`
);
const callLinkState = await calling.updateCallLinkName(prevCallLink, name);
const callLink = await dataInterface.updateCallLinkState(
roomId,
callLinkState
);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
};
}
function updateCallLinkRestrictions(
roomId: string,
restrictions: CallLinkRestrictions
): ThunkAction<void, RootStateType, unknown, HandleCallLinkUpdateActionType> {
return async dispatch => {
const prevCallLink = await dataInterface.getCallLinkByRoomId(roomId);
strictAssert(
prevCallLink,
`updateCallLinkRestrictions(${roomId}): call link not found`
);
const callLinkState = await calling.updateCallLinkRestrictions(
prevCallLink,
restrictions
);
const callLink = await dataInterface.updateCallLinkState(
roomId,
callLinkState
);
dispatch({
type: HANDLE_CALL_LINK_UPDATE,
payload: { callLink },
});
};
}
2024-04-01 19:19:35 +00:00
function startCallLinkLobbyByRoomId(
roomId: string
): StartCallLinkLobbyThunkActionType {
2024-02-22 21:19:50 +00:00
return async (dispatch, getState) => {
const state = getState();
2024-04-01 19:19:35 +00:00
const callLink = getOwn(state.calling.callLinks, roomId);
2024-02-22 21:19:50 +00:00
2024-04-01 19:19:35 +00:00
strictAssert(
callLink,
`startCallLinkLobbyByRoomId(${roomId}): call link not found`
);
2024-02-22 21:19:50 +00:00
2024-04-01 19:19:35 +00:00
const { rootKey } = callLink;
await _startCallLinkLobby({ rootKey, dispatch, getState });
};
}
2024-02-22 21:19:50 +00:00
2024-04-01 19:19:35 +00:00
function startCallLinkLobby({
rootKey,
}: StartCallLinkLobbyType): StartCallLinkLobbyThunkActionType {
return async (dispatch, getState) => {
await _startCallLinkLobby({ rootKey, dispatch, getState });
};
}
2024-02-22 21:19:50 +00:00
2024-04-01 19:19:35 +00:00
const _startCallLinkLobby = async ({
rootKey,
dispatch,
getState,
}: {
rootKey: string;
dispatch: ThunkDispatch<
RootStateType,
unknown,
| StartCallLinkLobbyActionType
| ShowErrorModalActionType
| TogglePipActionType
2024-04-01 19:19:35 +00:00
>;
getState: () => RootStateType;
}) => {
const callLinkRootKey = CallLinkRootKey.parse(rootKey);
const roomId = getRoomIdFromRootKey(callLinkRootKey);
2024-04-01 19:19:35 +00:00
const state = getState();
2024-02-22 21:19:50 +00:00
if (
handleActiveCallOnStartLobby({ conversationId: roomId, state, dispatch })
) {
2024-04-01 19:19:35 +00:00
return;
}
2024-02-22 21:19:50 +00:00
2024-04-25 17:09:05 +00:00
const readResult = await calling.readCallLink({ callLinkRootKey });
const { callLinkState } = readResult;
2024-04-01 19:19:35 +00:00
if (!callLinkState) {
const i18n = getIntl(getState());
2024-02-22 21:19:50 +00:00
dispatch({
2024-04-01 19:19:35 +00:00
type: SHOW_ERROR_MODAL,
2024-02-22 21:19:50 +00:00
payload: {
2024-04-01 19:19:35 +00:00
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-connection-issues'),
buttonVariant: ButtonVariant.Primary,
2024-02-22 21:19:50 +00:00
},
});
2024-04-01 19:19:35 +00:00
return;
}
2024-04-25 17:09:05 +00:00
if (
callLinkState.revoked ||
callLinkState.expiration < new Date().getTime()
) {
2024-04-01 19:19:35 +00:00
const i18n = getIntl(getState());
dispatch({
type: SHOW_ERROR_MODAL,
payload: {
title: i18n('icu:calling__cant-join'),
description: i18n('icu:calling__call-link-no-longer-valid'),
buttonVariant: ButtonVariant.Primary,
},
});
return;
}
try {
const callLinkExists = await dataInterface.callLinkExists(roomId);
if (callLinkExists) {
2024-04-25 17:09:05 +00:00
await dataInterface.updateCallLinkState(roomId, callLinkState);
2024-04-01 19:19:35 +00:00
log.info('startCallLinkLobby: Updated existing call link', roomId);
} else {
2024-04-25 17:09:05 +00:00
const { name, restrictions, expiration, revoked } = callLinkState;
2024-04-01 19:19:35 +00:00
await dataInterface.insertCallLink({
roomId,
rootKey,
adminKey: null,
name,
restrictions,
revoked,
expiration,
});
log.info('startCallLinkLobby: Saved new call link', roomId);
}
} catch (err) {
log.error(
'startCallLinkLobby: Call link DB error',
Errors.toLogFormat(err)
);
}
const groupCall = getGroupCall(roomId, state.calling, CallMode.Adhoc);
const groupCallDeviceCount =
groupCall?.peekInfo?.deviceCount ||
groupCall?.remoteParticipants.length ||
0;
const { adminKey } = getOwn(state.calling.callLinks, roomId) ?? {};
2024-06-10 15:23:43 +00:00
const adminPasskey = adminKey ? toAdminKeyBytes(adminKey) : undefined;
2024-04-01 19:19:35 +00:00
const callLobbyData = await calling.startCallLinkLobby({
callLinkRootKey,
adminPasskey,
2024-04-01 19:19:35 +00:00
hasLocalAudio: groupCallDeviceCount < 8,
});
if (!callLobbyData) {
return;
}
dispatch({
type: START_CALL_LINK_LOBBY,
payload: {
...callLobbyData,
2024-04-25 17:09:05 +00:00
callLinkState,
2024-05-17 23:22:51 +00:00
callLinkRoomId: roomId,
2024-04-01 19:19:35 +00:00
callLinkRootKey: rootKey,
conversationId: roomId,
isConversationTooBigToRing: false,
},
});
};
2024-02-22 21:19:50 +00:00
function startCallingLobby({
conversationId,
isVideoCall,
}: StartCallingLobbyType): ThunkAction<
void,
RootStateType,
unknown,
StartCallingLobbyActionType | TogglePipActionType
> {
return async (dispatch, getState) => {
const state = getState();
const conversation = getOwn(
state.conversations.conversationLookup,
conversationId
);
strictAssert(
conversation,
"startCallingLobby: can't start lobby without a conversation"
);
strictAssert(
!state.calling.activeCallState,
"startCallingLobby: can't start lobby if a call is active"
);
// The group call device count is considered 0 for a direct call.
2024-02-22 21:19:50 +00:00
const groupCall = getGroupCall(
conversationId,
state.calling,
CallMode.Group
);
const groupCallDeviceCount =
groupCall?.peekInfo?.deviceCount ||
groupCall?.remoteParticipants.length ||
0;
const callLobbyData = await calling.startCallingLobby({
conversation,
hasLocalAudio: groupCallDeviceCount < 8,
hasLocalVideo: isVideoCall,
});
if (!callLobbyData) {
return;
}
dispatch({
type: START_CALLING_LOBBY,
payload: {
...callLobbyData,
conversationId,
isConversationTooBigToRing: isConversationTooBigToRing(conversation),
},
});
2020-10-08 01:25:33 +00:00
};
}
2020-11-13 19:57:55 +00:00
function startCall(
payload: StartCallType
): ThunkAction<void, RootStateType, unknown, StartDirectCallActionType> {
return async (dispatch, getState) => {
2024-02-22 21:19:50 +00:00
const { conversationId, hasLocalAudio, hasLocalVideo } = payload;
2020-11-13 19:57:55 +00:00
switch (payload.callMode) {
case CallMode.Direct:
await calling.startOutgoingDirectCall(
2024-02-22 21:19:50 +00:00
conversationId,
hasLocalAudio,
hasLocalVideo
2020-11-13 19:57:55 +00:00
);
dispatch({
type: START_DIRECT_CALL,
payload,
});
break;
case CallMode.Group: {
2021-09-02 22:34:38 +00:00
let outgoingRing: boolean;
const state = getState();
const { activeCallState } = state.calling;
2023-12-07 23:59:54 +00:00
if (activeCallState?.outgoingRing) {
2021-09-02 22:34:38 +00:00
const conversation = getOwn(
state.conversations.conversationLookup,
activeCallState.conversationId
);
outgoingRing = Boolean(
conversation && !isConversationTooBigToRing(conversation)
);
} else {
outgoingRing = false;
}
await calling.joinGroupCall(
2024-02-22 21:19:50 +00:00
conversationId,
hasLocalAudio,
hasLocalVideo,
outgoingRing
2020-11-13 19:57:55 +00:00
);
// The calling service should already be wired up to Redux so we don't need to
// dispatch anything here.
break;
}
2024-02-22 21:19:50 +00:00
case CallMode.Adhoc: {
const state = getState();
const callLink = getOwn(state.calling.callLinks, conversationId);
if (!callLink) {
log.error(
`startCall: Failed to start call link call because roomId ${conversationId} is missing from calling state`
);
return;
}
await calling.joinCallLinkCall({
roomId: conversationId,
rootKey: callLink.rootKey,
adminKey: callLink.adminKey ?? undefined,
2024-02-22 21:19:50 +00:00
hasLocalAudio,
hasLocalVideo,
});
// The calling service should already be wired up to Redux so we don't need to
// dispatch anything here.
break;
}
2020-11-13 19:57:55 +00:00
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,
};
}
function toggleScreenRecordingPermissionsDialog(): ToggleNeedsScreenRecordingPermissionsActionType {
return {
type: TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS,
};
}
2020-08-27 00:03:42 +00:00
function toggleSettings(): ToggleSettingsActionType {
return {
type: TOGGLE_SETTINGS,
};
}
function changeCallView(mode: CallViewMode): ChangeCallViewActionType {
2021-01-08 22:57:54 +00:00
return {
type: CHANGE_CALL_VIEW,
viewMode: mode,
2021-01-08 22:57:54 +00:00
};
}
function switchToPresentationView(): SwitchToPresentationViewActionType {
return {
type: SWITCH_TO_PRESENTATION_VIEW,
};
}
function switchFromPresentationView(): SwitchFromPresentationViewActionType {
return {
type: SWITCH_FROM_PRESENTATION_VIEW,
};
}
2020-06-04 18:16:19 +00:00
export const actions = {
acceptCall,
approveUser,
batchUserAction,
2024-06-29 00:13:20 +00:00
blockClient,
2020-06-04 18:16:19 +00:00
callStateChange,
cancelCall,
2021-08-20 16:06:15 +00:00
cancelIncomingGroupCallRing,
changeCallView,
2020-08-27 00:03:42 +00:00
changeIODevice,
2020-10-01 19:09:15 +00:00
closeNeedPermissionScreen,
2024-06-10 15:23:43 +00:00
createCallLink,
2020-06-04 18:16:19 +00:00
declineCall,
denyUser,
getPresentingSources,
groupCallAudioLevelsChange,
2024-02-22 21:19:50 +00:00
groupCallEnded,
2023-12-06 21:52:29 +00:00
groupCallRaisedHandsChange,
2020-11-13 19:57:55 +00:00
groupCallStateChange,
2022-01-24 20:32:09 +00:00
hangUpActiveCall,
2024-04-25 17:09:05 +00:00
handleCallLinkUpdate,
joinedAdhocCall,
onOutgoingVideoCallInConversation,
onOutgoingAudioCallInConversation,
openSystemPreferencesAction,
2020-06-04 18:16:19 +00:00
outgoingCall,
peekGroupCallForTheFirstTime,
peekGroupCallIfItHasMembers,
2020-11-20 17:19:28 +00:00
peekNotConnectedGroupCall,
2023-11-16 19:55:35 +00:00
receiveGroupCallReactions,
2021-08-20 16:06:15 +00:00
receiveIncomingDirectCall,
receiveIncomingGroupCall,
2020-08-27 00:03:42 +00:00
refreshIODevices,
remoteSharingScreenChange,
2020-06-04 18:16:19 +00:00
remoteVideoChange,
removeClient,
returnToActiveCall,
2023-12-06 21:52:29 +00:00
sendGroupCallRaiseHand,
2023-11-16 19:55:35 +00:00
sendGroupCallReaction,
setGroupCallVideoRequest,
setIsCallActive,
2020-06-04 18:16:19 +00:00
setLocalAudio,
setLocalPreview,
2020-06-04 18:16:19 +00:00
setLocalVideo,
setOutgoingRing,
setPresenting,
setRendererCanvas,
2020-10-08 01:25:33 +00:00
startCall,
2024-02-22 21:19:50 +00:00
startCallLinkLobby,
2024-04-01 19:19:35 +00:00
startCallLinkLobbyByRoomId,
startCallingLobby,
switchToPresentationView,
switchFromPresentationView,
2020-10-08 01:25:33 +00:00
toggleParticipants,
2020-10-01 00:43:05 +00:00
togglePip,
toggleScreenRecordingPermissionsDialog,
2020-08-27 00:03:42 +00:00
toggleSettings,
2024-06-10 15:23:43 +00:00
updateCallLinkName,
updateCallLinkRestrictions,
2020-06-04 18:16:19 +00:00
};
export const useCallingActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
export type ActionsType = ReadonlyDeep<typeof actions>;
2020-06-04 18:16:19 +00:00
// 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: {},
2024-02-22 21:19:50 +00:00
adhocCalls: {},
activeCallState: undefined,
2024-02-22 21:19:50 +00:00
callLinks: {},
};
}
2021-08-20 16:06:15 +00:00
function getGroupCall(
2020-12-02 18:14:03 +00:00
conversationId: string,
2024-02-22 21:19:50 +00:00
state: Readonly<CallingStateType>,
callMode: CallMode
2021-08-20 16:06:15 +00:00
): undefined | GroupCallStateType {
2024-02-22 21:19:50 +00:00
const call =
callMode === CallMode.Adhoc
? getOwn(state.adhocCalls, conversationId)
: getOwn(state.callsByConversation, conversationId);
return isGroupOrAdhocCallState(call) ? call : undefined;
2020-12-02 18:14:03 +00:00
}
function removeConversationFromState(
state: Readonly<CallingStateType>,
conversationId: string
): CallingStateType {
return {
...(conversationId === state.activeCallState?.conversationId
? omit(state, 'activeCallState')
: state),
callsByConversation: omit(state.callsByConversation, conversationId),
2024-02-22 21:19:50 +00:00
adhocCalls: omit(state.adhocCalls, conversationId),
};
}
function mergeCallWithGroupCallLookups({
state,
callMode,
conversationId,
call,
}: {
state: Readonly<CallingStateType>;
callMode: CallMode;
conversationId: string;
call: GroupCallStateType;
}): {
callsByConversation: CallsByConversationType;
adhocCalls: AdhocCallsType;
} {
const { callsByConversation, adhocCalls } = state;
const isAdhocCall = callMode === CallMode.Adhoc;
return {
callsByConversation: isAdhocCall
? callsByConversation
: {
...callsByConversation,
[conversationId]: call,
},
adhocCalls: isAdhocCall
? {
...adhocCalls,
[conversationId]: call,
}
: adhocCalls,
2020-06-04 18:16:19 +00:00
};
}
export function reducer(
state: Readonly<CallingStateType> = getEmptyState(),
action: Readonly<CallingActionType>
2020-06-04 18:16:19 +00:00
): CallingStateType {
2024-02-22 21:19:50 +00:00
const { callsByConversation, adhocCalls } = state;
2024-02-22 21:19:50 +00:00
if (
action.type === START_CALLING_LOBBY ||
action.type === START_CALL_LINK_LOBBY
) {
const { callMode, conversationId } = action.payload;
2021-08-20 16:06:15 +00:00
2020-11-13 19:57:55 +00:00
let call: DirectCallStateType | GroupCallStateType;
2024-02-22 21:19:50 +00:00
let newAdhocCalls: AdhocCallsType;
let outgoingRing: boolean;
2024-02-22 21:19:50 +00:00
switch (callMode) {
2020-11-13 19:57:55 +00:00
case CallMode.Direct:
call = {
callMode: CallMode.Direct,
2021-08-20 16:06:15 +00:00
conversationId,
2020-11-13 19:57:55 +00:00
isIncoming: false,
isVideoCall: action.payload.hasLocalVideo,
};
outgoingRing = true;
2024-02-22 21:19:50 +00:00
newAdhocCalls = adhocCalls;
2020-11-13 19:57:55 +00:00
break;
2024-02-22 21:19:50 +00:00
case CallMode.Group:
case CallMode.Adhoc: {
const { connectionState, joinState, peekInfo, remoteParticipants } =
action.payload;
2020-11-13 19:57:55 +00:00
// We expect to be in this state briefly. The Calling service should update the
// call state shortly.
2024-02-22 21:19:50 +00:00
const existingCall = getGroupCall(conversationId, state, callMode);
2021-09-02 22:34:38 +00:00
const ringState = getGroupCallRingState(existingCall);
2020-11-13 19:57:55 +00:00
call = {
2024-02-22 21:19:50 +00:00
callMode,
2021-08-20 16:06:15 +00:00
conversationId,
2024-02-22 21:19:50 +00:00
connectionState,
joinState,
2023-11-16 19:55:35 +00:00
localDemuxId: undefined,
2024-02-22 21:19:50 +00:00
peekInfo: peekInfo ||
2021-08-20 16:06:15 +00:00
existingCall?.peekInfo || {
2024-02-22 21:19:50 +00:00
acis: remoteParticipants.map(({ aci }) => aci),
pendingAcis: [],
2020-12-02 18:14:03 +00:00
maxDevices: Infinity,
2024-02-22 21:19:50 +00:00
deviceCount: remoteParticipants.length,
2020-12-02 18:14:03 +00:00
},
2024-02-22 21:19:50 +00:00
remoteParticipants,
2021-09-02 22:34:38 +00:00
...ringState,
2020-11-13 19:57:55 +00:00
};
2024-03-01 02:04:37 +00:00
if (callMode === CallMode.Group) {
outgoingRing =
!ringState.ringId &&
!call.peekInfo?.acis.length &&
!call.remoteParticipants.length &&
!action.payload.isConversationTooBigToRing;
newAdhocCalls = adhocCalls;
} else if (callMode === CallMode.Adhoc) {
outgoingRing = false;
newAdhocCalls = {
...adhocCalls,
[conversationId]: call,
};
} else {
throw missingCaseError(action.payload);
}
2020-11-13 19:57:55 +00:00
break;
2021-08-20 16:06:15 +00:00
}
2020-11-13 19:57:55 +00:00
default:
throw missingCaseError(action.payload);
}
2024-02-22 21:19:50 +00:00
const { callLinks } = state;
const newCallsByConversation =
callMode === CallMode.Adhoc
? callsByConversation
: {
...callsByConversation,
[conversationId]: call,
};
2020-10-08 01:25:33 +00:00
return {
...state,
2024-02-22 21:19:50 +00:00
callsByConversation: newCallsByConversation,
adhocCalls: newAdhocCalls,
callLinks:
action.type === START_CALL_LINK_LOBBY
? {
...callLinks,
[conversationId]: {
...action.payload.callLinkState,
2024-05-17 23:22:51 +00:00
roomId:
callLinks[conversationId]?.roomId ??
action.payload.callLinkRoomId,
rootKey:
callLinks[conversationId]?.rootKey ??
action.payload.callLinkRootKey,
adminKey: callLinks[conversationId]?.adminKey,
2024-02-22 21:19:50 +00:00
},
}
: callLinks,
activeCallState: {
2024-02-22 21:19:50 +00:00
callMode,
conversationId,
2020-11-13 19:57:55 +00:00
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2022-05-19 03:28:51 +00:00
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
outgoingRing,
joinedAt: null,
},
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: {
2024-02-22 21:19:50 +00:00
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2022-05-19 03:28:51 +00:00
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
outgoingRing: true,
joinedAt: null,
},
2020-10-08 01:25:33 +00:00
};
}
2020-11-04 17:47:50 +00:00
if (action.type === ACCEPT_CALL_PENDING) {
2024-02-22 21:19:50 +00:00
const call = getOwn(
state.callsByConversation,
action.payload.conversationId
);
if (!call) {
log.warn('Unable to accept a non-existent call');
return state;
}
2020-06-04 18:16:19 +00:00
return {
...state,
activeCallState: {
2024-02-22 21:19:50 +00:00
callMode: call.callMode,
conversationId: action.payload.conversationId,
hasLocalAudio: true,
hasLocalVideo: action.payload.asVideoCall,
2022-05-19 03:28:51 +00:00
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
outgoingRing: false,
joinedAt: null,
},
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) {
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:
2024-04-01 19:19:35 +00:00
case CallMode.Adhoc:
2020-11-13 19:57:55 +00:00
return omit(state, 'activeCallState');
default:
throw missingCaseError(activeCall);
}
}
2021-08-20 16:06:15 +00:00
if (action.type === CANCEL_INCOMING_GROUP_CALL_RING) {
const { conversationId, ringId } = action.payload;
2024-02-22 21:19:50 +00:00
const groupCall = getGroupCall(conversationId, state, CallMode.Group);
2021-08-20 16:06:15 +00:00
if (!groupCall || groupCall.ringId !== ringId) {
return state;
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: omit(groupCall, ['ringId', 'ringerAci']),
2021-08-20 16:06:15 +00:00
},
};
}
2021-09-02 22:34:38 +00:00
if (action.type === 'CONVERSATION_CHANGED') {
const activeCall = getActiveCall(state);
const { activeCallState } = state;
if (
!activeCallState?.outgoingRing ||
activeCallState.conversationId !== action.payload.id ||
2024-02-22 21:19:50 +00:00
!isGroupOrAdhocCallState(activeCall) ||
2021-09-02 22:34:38 +00:00
activeCall.joinState !== GroupCallJoinState.NotJoined ||
!isConversationTooBigToRing(action.payload.data)
) {
return state;
}
return {
...state,
activeCallState: { ...activeCallState, outgoingRing: false },
};
}
if (action.type === 'CONVERSATION_REMOVED') {
return removeConversationFromState(state, action.payload.id);
}
2021-08-20 16:06:15 +00:00
if (action.type === DECLINE_DIRECT_CALL) {
return removeConversationFromState(state, action.payload.conversationId);
2020-06-04 18:16:19 +00:00
}
2021-08-20 16:06:15 +00:00
if (action.type === INCOMING_DIRECT_CALL) {
2020-06-04 18:16:19 +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: true,
isVideoCall: action.payload.isVideoCall,
},
},
2020-06-04 18:16:19 +00:00
};
}
2021-08-20 16:06:15 +00:00
if (action.type === INCOMING_GROUP_CALL) {
const { conversationId, ringId, ringerAci } = action.payload;
2021-08-20 16:06:15 +00:00
let groupCall: GroupCallStateType;
2024-02-22 21:19:50 +00:00
const existingGroupCall = getGroupCall(
conversationId,
state,
CallMode.Group
);
2021-08-20 16:06:15 +00:00
if (existingGroupCall) {
if (existingGroupCall.ringerAci) {
log.info('Group call was already ringing');
2021-08-20 16:06:15 +00:00
return state;
}
if (existingGroupCall.joinState !== GroupCallJoinState.NotJoined) {
log.info("Got a ring for a call we're already in");
2021-08-20 16:06:15 +00:00
return state;
}
groupCall = {
...existingGroupCall,
ringId,
ringerAci,
2021-08-20 16:06:15 +00:00
};
} else {
groupCall = {
callMode: CallMode.Group,
conversationId,
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
2023-11-16 19:55:35 +00:00
localDemuxId: undefined,
2021-08-20 16:06:15 +00:00
peekInfo: {
acis: [],
pendingAcis: [],
2021-08-20 16:06:15 +00:00
maxDevices: Infinity,
deviceCount: 0,
},
remoteParticipants: [],
ringId,
ringerAci,
2021-08-20 16:06:15 +00:00
};
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: groupCall,
},
};
}
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: {
2024-02-22 21:19:50 +00:00
callMode: CallMode.Direct,
conversationId: action.payload.conversationId,
hasLocalAudio: action.payload.hasLocalAudio,
hasLocalVideo: action.payload.hasLocalVideo,
2022-05-19 03:28:51 +00:00
localAudioLevel: 0,
viewMode: CallViewMode.Paginated,
pip: false,
settingsDialogOpen: false,
showParticipantsList: false,
outgoingRing: true,
joinedAt: null,
},
2020-06-04 18:16:19 +00:00
};
}
if (action.type === CALL_STATE_CHANGE_FULFILLED) {
const call = getOwn(
state.callsByConversation,
action.payload.conversationId
);
if (
call?.callMode === CallMode.Direct &&
call?.callState !== action.payload.callState
) {
drop(
calling.notifyScreenShareStatus({
callMode: CallMode.Direct,
callState: action.payload.callState,
isPresenting: state.activeCallState?.presentingSource != null,
conversationId: state.activeCallState?.conversationId,
})
);
}
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
}
2020-11-13 19:57:55 +00:00
if (call?.callMode !== CallMode.Direct) {
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 ?? null,
};
} 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
};
}
if (action.type === GROUP_CALL_AUDIO_LEVELS_CHANGE) {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId, remoteDeviceStates } = action.payload;
const { activeCallState } = state;
2024-02-22 21:19:50 +00:00
const existingCall = getGroupCall(conversationId, state, callMode);
// The PiP check is an optimization. We don't need to update audio levels if the user
// cannot see them.
if (!activeCallState || activeCallState.pip || !existingCall) {
return state;
}
2022-05-19 03:28:51 +00:00
const localAudioLevel = truncateAudioLevel(action.payload.localAudioLevel);
2022-05-19 03:28:51 +00:00
const remoteAudioLevels = new Map<number, number>();
remoteDeviceStates.forEach(({ audioLevel, demuxId }) => {
// We expect `audioLevel` to be a number but have this check just in case.
2022-05-19 03:28:51 +00:00
if (typeof audioLevel !== 'number') {
return;
}
const graded = truncateAudioLevel(audioLevel);
if (graded > 0) {
remoteAudioLevels.set(demuxId, graded);
}
});
// This action is dispatched frequently. This equality check helps avoid re-renders.
2022-05-19 03:28:51 +00:00
const oldLocalAudioLevel = activeCallState.localAudioLevel;
const oldRemoteAudioLevels = existingCall.remoteAudioLevels;
if (
2022-05-19 03:28:51 +00:00
oldLocalAudioLevel === localAudioLevel &&
oldRemoteAudioLevels &&
mapUtil.isEqual(oldRemoteAudioLevels, remoteAudioLevels)
) {
return state;
}
return {
...state,
2022-05-19 03:28:51 +00:00
activeCallState: { ...activeCallState, localAudioLevel },
2024-02-22 21:19:50 +00:00
...mergeCallWithGroupCallLookups({
state,
callMode: existingCall.callMode,
conversationId,
call: { ...existingCall, remoteAudioLevels },
}),
};
}
2020-11-13 19:57:55 +00:00
if (action.type === GROUP_CALL_STATE_CHANGE) {
const {
2024-02-22 21:19:50 +00:00
callMode,
2020-11-13 19:57:55 +00:00
connectionState,
2020-11-20 17:19:28 +00:00
conversationId,
2020-11-13 19:57:55 +00:00
hasLocalAudio,
hasLocalVideo,
2023-11-16 19:55:35 +00:00
localDemuxId,
2020-11-20 17:19:28 +00:00
joinState,
ourAci,
2020-11-20 17:19:28 +00:00
peekInfo,
2020-11-13 19:57:55 +00:00
remoteParticipants,
} = action.payload;
2024-02-22 21:19:50 +00:00
const existingCall = getGroupCall(conversationId, state, callMode);
2021-08-20 16:06:15 +00:00
const existingRingState = getGroupCallRingState(existingCall);
2024-03-19 17:40:37 +00:00
// Generare a better log line that would help piece together ACIs and
// demuxIds.
const currentlyInCall = new Map(
existingCall?.remoteParticipants.map(({ demuxId, aci }) => [
demuxId,
aci,
]) ?? []
);
const nextInCall = new Map(
remoteParticipants.map(({ demuxId, aci }) => [demuxId, aci]) ?? []
);
const membersLeft = new Array<`${AciString}:${number}`>();
for (const [demuxId, aci] of currentlyInCall) {
if (!nextInCall.has(demuxId)) {
membersLeft.push(`${aci}:${demuxId}`);
}
}
const membersJoined = new Array<`${AciString}:${number}`>();
for (const [demuxId, aci] of nextInCall) {
if (!currentlyInCall.has(demuxId)) {
membersJoined.push(`${aci}:${demuxId}`);
}
}
log.info(
'groupCallStateChange:',
conversationId,
GroupCallConnectionState[connectionState],
GroupCallJoinState[joinState],
`joined={${membersJoined.join(', ')}}`,
`left={${membersLeft.join(', ')}}`
);
2020-12-02 18:14:03 +00:00
const newPeekInfo = peekInfo ||
2021-08-20 16:06:15 +00:00
existingCall?.peekInfo || {
acis: remoteParticipants.map(({ aci }) => aci),
pendingAcis: [],
2020-12-02 18:14:03 +00:00
maxDevices: Infinity,
deviceCount: remoteParticipants.length,
};
2020-11-20 17:19:28 +00:00
let newActiveCallState: ActiveCallStateType | undefined;
if (state.activeCallState?.conversationId === conversationId) {
2020-11-20 17:19:28 +00:00
newActiveCallState =
connectionState === GroupCallConnectionState.NotConnected
2020-11-20 17:19:28 +00:00
? undefined
: {
2020-11-20 17:19:28 +00:00
...state.activeCallState,
hasLocalAudio,
hasLocalVideo,
};
} else {
newActiveCallState = state.activeCallState;
2020-11-13 19:57:55 +00:00
}
if (
newActiveCallState &&
newActiveCallState.outgoingRing &&
newActiveCallState.conversationId === conversationId &&
isAnybodyElseInGroupCall(newPeekInfo, ourAci)
) {
newActiveCallState = {
...newActiveCallState,
outgoingRing: false,
};
}
2021-08-20 16:06:15 +00:00
let newRingState: GroupCallRingStateType;
if (joinState === GroupCallJoinState.NotJoined) {
newRingState = existingRingState;
} else {
newRingState = {};
}
2024-02-22 21:19:50 +00:00
const call = {
callMode,
conversationId,
connectionState,
joinState,
localDemuxId,
peekInfo: newPeekInfo,
remoteParticipants,
raisedHands: existingCall?.raisedHands ?? [],
...newRingState,
};
if (existingCall?.connectionState !== connectionState) {
drop(
calling.notifyScreenShareStatus({
callMode,
connectionState,
isPresenting: state.activeCallState?.presentingSource != null,
conversationId: state.activeCallState?.conversationId,
})
);
}
2020-11-13 19:57:55 +00:00
return {
...state,
2024-02-22 21:19:50 +00:00
...mergeCallWithGroupCallLookups({
state,
callMode,
conversationId,
call,
}),
2020-11-20 17:19:28 +00:00
activeCallState: newActiveCallState,
};
}
if (action.type === PEEK_GROUP_CALL_FULFILLED) {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId, peekInfo } = action.payload;
if (!isGroupOrAdhocCallMode(callMode)) {
return state;
}
2020-11-20 17:19:28 +00:00
2021-08-20 16:06:15 +00:00
const existingCall: GroupCallStateType = getGroupCall(
conversationId,
2024-02-22 21:19:50 +00:00
state,
callMode
2021-08-20 16:06:15 +00:00
) || {
callMode,
2020-11-20 17:19:28 +00:00
conversationId,
connectionState: GroupCallConnectionState.NotConnected,
joinState: GroupCallJoinState.NotJoined,
2023-11-16 19:55:35 +00:00
localDemuxId: undefined,
2020-11-20 17:19:28 +00:00
peekInfo: {
acis: [],
pendingAcis: [],
2020-11-20 17:19:28 +00:00
maxDevices: Infinity,
deviceCount: 0,
},
remoteParticipants: [],
};
// 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;
}
return {
...state,
2024-02-22 21:19:50 +00:00
...mergeCallWithGroupCallLookups({
state,
callMode: existingCall.callMode,
conversationId,
call: { ...existingCall, peekInfo },
}),
2020-11-13 19:57:55 +00:00
};
}
2023-11-16 19:55:35 +00:00
if (
action.type === SEND_GROUP_CALL_REACTION ||
action.type === GROUP_CALL_REACTIONS_RECEIVED
) {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId, timestamp } = action.payload;
2023-11-16 19:55:35 +00:00
if (state.activeCallState?.conversationId !== conversationId) {
return state;
}
let recentReactions: Array<ActiveCallReaction> = [];
if (action.type === GROUP_CALL_REACTIONS_RECEIVED) {
recentReactions = action.payload.reactions.map(({ demuxId, value }) => {
return { timestamp, demuxId, value };
});
} else {
// When sending reactions, ringrtc doesn't automatically receive back a copy of
// the reaction you just sent. We handle it here and add a local copy to state.
2024-02-22 21:19:50 +00:00
const existingGroupCall = getGroupCall(conversationId, state, callMode);
2023-11-16 19:55:35 +00:00
if (!existingGroupCall) {
log.warn(
'Unable to update group call reactions after send reaction because existing group call is missing.'
);
return state;
}
// This should never happen -- localDemuxId is set when a call enters the
// Joining state, and Reactions are only usable from the CallScreen which is
// shown when the call is in the Joined state (after Joining).
if (!existingGroupCall.localDemuxId) {
log.warn(
'Unable to update group call reactions after send reaction because localDemuxId is missing.'
);
return state;
}
recentReactions = [
{
timestamp,
demuxId: existingGroupCall.localDemuxId,
value: action.payload.value,
},
];
}
return {
...state,
activeCallState: {
...state.activeCallState,
reactions: [
...(state.activeCallState.reactions ?? []),
...recentReactions,
].slice(-MAX_CALLING_REACTIONS),
},
};
}
if (action.type === GROUP_CALL_REACTIONS_EXPIRED) {
const { conversationId, timestamp: receivedAt } = action.payload;
if (
state.activeCallState?.conversationId !== conversationId ||
!state.activeCallState?.reactions
) {
return state;
}
const expireAt = receivedAt + CALLING_REACTIONS_LIFETIME;
return {
...state,
activeCallState: {
...state.activeCallState,
reactions: state.activeCallState.reactions.filter(({ timestamp }) => {
return timestamp > expireAt;
}),
},
};
}
2023-12-06 21:52:29 +00:00
if (action.type === GROUP_CALL_RAISED_HANDS_CHANGE) {
2024-02-22 21:19:50 +00:00
const { callMode, conversationId, raisedHands } = action.payload;
2023-12-06 21:52:29 +00:00
const { activeCallState } = state;
2024-02-22 21:19:50 +00:00
const existingCall = getGroupCall(conversationId, state, callMode);
2023-12-06 21:52:29 +00:00
if (
state.activeCallState?.conversationId !== conversationId ||
!activeCallState ||
!existingCall
) {
return state;
}
return {
...state,
2024-02-22 21:19:50 +00:00
...mergeCallWithGroupCallLookups({
state,
callMode: existingCall.callMode,
conversationId,
call: { ...existingCall, raisedHands: [...raisedHands] },
}),
2023-12-06 21:52:29 +00:00
};
}
if (action.type === REMOTE_SHARING_SCREEN_CHANGE) {
const { conversationId, isSharingScreen } = action.payload;
const call = getOwn(state.callsByConversation, conversationId);
if (call?.callMode !== CallMode.Direct) {
log.warn('Cannot update remote video for a non-direct call');
return state;
}
return {
...state,
callsByConversation: {
...callsByConversation,
[conversationId]: {
...call,
isSharingScreen,
},
},
};
}
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) {
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 === RETURN_TO_ACTIVE_CALL) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot return to active call if there is no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
pip: false,
},
};
}
if (action.type === SET_LOCAL_AUDIO_FULFILLED) {
if (!state.activeCallState) {
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) {
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) {
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) {
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) {
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
};
}
if (action.type === SET_PRESENTING) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot toggle presenting when there is no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
presentingSource: action.payload,
presentingSourcesAvailable: undefined,
},
};
}
if (action.type === SET_PRESENTING_SOURCES) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot set presenting sources when there is no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
presentingSourcesAvailable: action.payload,
},
};
}
if (action.type === SET_OUTGOING_RING) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot set outgoing ring when there is no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
outgoingRing: action.payload,
},
};
}
if (action.type === TOGGLE_NEEDS_SCREEN_RECORDING_PERMISSIONS) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot set presenting sources when there is no active call');
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
2021-11-11 22:43:05 +00:00
showNeedsScreenRecordingPermissionsWarning:
!activeCallState.showNeedsScreenRecordingPermissionsWarning,
},
};
}
if (action.type === CHANGE_CALL_VIEW) {
2021-01-08 22:57:54 +00:00
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot change call view when there is no active call');
2021-01-08 22:57:54 +00:00
return state;
}
if (activeCallState.viewMode === action.viewMode) {
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
viewMode: action.viewMode,
viewModeBeforePresentation:
action.viewMode === CallViewMode.Presentation
? activeCallState.viewMode
: undefined,
},
};
}
if (action.type === SWITCH_TO_PRESENTATION_VIEW) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot switch to speaker view when there is no active call');
return state;
}
if (activeCallState.viewMode === CallViewMode.Presentation) {
return state;
}
return {
...state,
activeCallState: {
...activeCallState,
viewMode: CallViewMode.Presentation,
viewModeBeforePresentation: activeCallState.viewMode,
},
};
}
if (action.type === SWITCH_FROM_PRESENTATION_VIEW) {
const { activeCallState } = state;
if (!activeCallState) {
log.warn('Cannot switch to speaker view when there is no active call');
return state;
}
if (activeCallState.viewMode !== CallViewMode.Presentation) {
return state;
}
2021-01-08 22:57:54 +00:00
return {
...state,
activeCallState: {
...activeCallState,
viewMode:
activeCallState.viewModeBeforePresentation ?? CallViewMode.Paginated,
2021-01-08 22:57:54 +00:00
},
};
}
2024-04-25 17:09:05 +00:00
if (action.type === HANDLE_CALL_LINK_UPDATE) {
const { callLinks } = state;
2024-05-17 23:22:51 +00:00
const { callLink } = action.payload;
const { roomId } = callLink;
2024-04-25 17:09:05 +00:00
return {
...state,
callLinks: {
...callLinks,
2024-05-17 23:22:51 +00:00
[roomId]: callLink,
2024-04-25 17:09:05 +00:00
},
};
}
2020-06-04 18:16:19 +00:00
return state;
}