[MM-58581] Calls: End call confirmations (#8004)

* end call confirmations

* i18n

* remove unneeded asyncs

* better alert for android
This commit is contained in:
Christopher Poile 2024-06-18 10:02:24 -04:00 committed by GitHub
parent 8cef881db6
commit 1f2a71c499
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 209 additions and 38 deletions

View file

@ -24,7 +24,10 @@ import {
handleCallRecordingState,
handleCallScreenOff,
handleCallScreenOn,
handleCallStarted, handleCallUserConnected, handleCallUserDisconnected,
handleCallStarted,
handleCallState,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserJoined,
handleCallUserLeft,
handleCallUserMuted,
@ -450,6 +453,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.CALLS_HOST_REMOVED:
handleHostRemoved(serverUrl, msg);
break;
case WebsocketEvents.CALLS_CALL_STATE:
handleCallState(serverUrl, msg);
break;
case WebsocketEvents.GROUP_RECEIVED:
handleGroupReceivedEvent(serverUrl, msg);

View file

@ -341,6 +341,12 @@ const Post = ({
<CallsCustomMessage
serverUrl={serverUrl}
post={post}
// Note: the below are provided by the index, but typescript seems to be having problems.
otherParticipants={false}
isAdmin={false}
isHost={false}
joiningChannelId={null}
/>
);
} else {

View file

@ -91,6 +91,7 @@ const WebsocketEvents = {
CALLS_HOST_MUTE: `custom_${Calls.PluginId}_host_mute`,
CALLS_HOST_LOWER_HAND: `custom_${Calls.PluginId}_host_lower_hand`,
CALLS_HOST_REMOVED: `custom_${Calls.PluginId}_host_removed`,
CALLS_CALL_STATE: `custom_${Calls.PluginId}_call_state`,
GROUP_RECEIVED: 'received_group',
GROUP_MEMBER_ADD: 'group_member_add',

View file

@ -8,6 +8,7 @@ import {forceLogoutIfNecessary} from '@actions/remote/session';
import {updateThreadFollowing} from '@actions/remote/thread';
import {fetchUsersByIds} from '@actions/remote/user';
import {
endCallConfirmationAlert,
leaveAndJoinWithAlert,
needsRecordingErrorAlert,
needsRecordingWillBePostedAlert,
@ -28,6 +29,7 @@ import {
setScreenShareURL,
setSpeakerPhone,
} from '@calls/state';
import {type AudioDevice, type Call, type CallSession, type CallsConnection, EndCallReturn} from '@calls/types/calls';
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
import DatabaseManager from '@database/manager';
@ -44,7 +46,6 @@ import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/u
import {newConnection} from '../connection/connection';
import type {AudioDevice, Call, CallSession, CallsConnection} from '@calls/types/calls';
import type {CallChannelState, CallState, EmojiData, SessionState} from '@mattermost/calls/lib/types';
import type {IntlShape} from 'react-intl';
@ -132,7 +133,7 @@ export const loadCallForChannel = async (serverUrl: string, channelId: string) =
fetchUsersByIds(serverUrl, Array.from(ids));
}
setCallForChannel(serverUrl, channelId, resp.enabled, call);
setCallForChannel(serverUrl, channelId, call, resp.enabled);
return {data: {call, enabled: resp.enabled}};
};
@ -160,11 +161,12 @@ const convertOldCallToNew = (call: CallState): CallState => {
};
};
const createCallAndAddToIds = (channelId: string, call: CallState, ids: Set<string>) => {
return {
export const createCallAndAddToIds = (channelId: string, call: CallState, ids?: Set<string>) => {
// Don't cast so that we get alerted to missing types
const convertedCall: Call = {
sessions: Object.values(call.sessions).reduce((accum, cur) => {
// Add the id to the set of UserModels we want to ensure are loaded.
ids.add(cur.user_id);
ids?.add(cur.user_id);
// Create the CallParticipant
accum[cur.session_id] = {
@ -184,7 +186,9 @@ const createCallAndAddToIds = (channelId: string, call: CallState, ids: Set<stri
hostId: call.host_id,
recState: call.recording,
dismissed: call.dismissed_notification || {},
} as Call;
};
return convertedCall;
};
export const loadConfigAndCalls = async (serverUrl: string, userId: string) => {
@ -302,6 +306,29 @@ export const leaveCall = (err?: Error) => {
}
};
export const leaveCallConfirmation = async (
intl: IntlShape,
otherParticipants: boolean,
isAdmin: boolean,
isHost: boolean,
serverUrl: string,
channelId: string,
leaveCb?: () => void) => {
const showHostControls = (isHost || isAdmin) && otherParticipants;
const ret = await endCallConfirmationAlert(intl, showHostControls) as EndCallReturn;
switch (ret) {
case EndCallReturn.Cancel:
return;
case EndCallReturn.LeaveCall:
leaveCall();
leaveCb?.();
return;
case EndCallReturn.EndCall:
endCall(serverUrl, channelId);
leaveCb?.();
}
};
export const muteMyself = () => {
if (connection) {
connection.mute();

View file

@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Alert} from 'react-native';
import {Alert, type AlertButton, Platform} from 'react-native';
import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions';
import {dismissIncomingCall, hostRemove} from '@calls/actions/calls';
@ -15,6 +15,7 @@ import {
removeIncomingCall,
setMicPermissionsGranted,
} from '@calls/state';
import {EndCallReturn} from '@calls/types/calls';
import {errorAlert} from '@calls/utils';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
@ -132,7 +133,7 @@ export const leaveAndJoinWithAlert = async (
id: 'mobile.post.cancel',
defaultMessage: 'Cancel',
}),
onPress: async () => resolve(false),
onPress: () => resolve(false),
style: 'destructive',
},
{
@ -473,3 +474,61 @@ export const removeFromCall = (serverUrl: string, displayName: string, callId: s
style: 'destructive',
}]);
};
export const endCallConfirmationAlert = (intl: IntlShape, showHostControls: boolean) => {
const {formatMessage} = intl;
const asyncAlert = async () => new Promise((resolve) => {
const buttons: AlertButton[] = [{
text: formatMessage({
id: 'mobile.calls_cancel',
defaultMessage: 'Cancel',
}),
onPress: () => resolve(EndCallReturn.Cancel),
style: 'cancel',
}, {
text: formatMessage({
id: 'mobile.calls_host_leave_confirm',
defaultMessage: 'Leave call',
}),
onPress: () => resolve(EndCallReturn.LeaveCall),
style: 'destructive',
}];
const questionMsg = formatMessage({
id: 'mobile.calls_host_leave_title',
defaultMessage: 'Are you sure you want to leave this call?',
});
if (showHostControls) {
const endCallButton: AlertButton = {
text: formatMessage({
id: 'mobile.calls_host_end_confirm',
defaultMessage: 'End call for everyone',
}),
onPress: () => resolve(EndCallReturn.EndCall),
style: 'destructive',
};
const leaveCallButton = {
text: formatMessage({
id: 'mobile.calls_host_leave_confirm',
defaultMessage: 'Leave call',
}),
onPress: () => resolve(EndCallReturn.LeaveCall),
};
if (Platform.OS === 'ios') {
buttons.splice(1, 1, endCallButton, leaveCallButton);
} else {
buttons.splice(1, 1, leaveCallButton, endCallButton);
}
}
if (Platform.OS === 'ios') {
Alert.alert(questionMsg, '', buttons);
} else {
Alert.alert('', questionMsg, buttons);
}
});
return asyncAlert();
};

View file

@ -6,7 +6,7 @@ import React, {useCallback} from 'react';
import {useIntl} from 'react-intl';
import {Text, TouchableOpacity, View} from 'react-native';
import {leaveCall} from '@calls/actions';
import {leaveCallConfirmation} from '@calls/actions/calls';
import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts';
import {setJoiningChannelId} from '@calls/state';
import CompassIcon from '@components/compass_icon';
@ -26,11 +26,14 @@ import type UserModel from '@typings/database/models/servers/user';
type Props = {
post: PostModel;
currentUser?: UserModel;
isMilitaryTime: boolean;
joiningChannelId: string | null;
otherParticipants: boolean;
isAdmin: boolean;
isHost: boolean;
currentUser?: UserModel;
limitRestrictedInfo?: LimitRestrictedInfo;
ccChannelId?: string;
joiningChannelId: string | null;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@ -131,6 +134,9 @@ export const CallsCustomMessage = ({
ccChannelId,
limitRestrictedInfo,
joiningChannelId,
otherParticipants,
isAdmin,
isHost,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
@ -154,9 +160,9 @@ export const CallsCustomMessage = ({
setJoiningChannelId(null);
}, [limitRestrictedInfo, intl, serverUrl, post.channelId]);
const leaveHandler = useCallback(() => {
leaveCall();
}, []);
const leaveCallHandler = useCallback(() => {
leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, post.channelId);
}, [intl, otherParticipants, isAdmin, isHost, serverUrl, post.channelId]);
const title = post.props.title ? (
<Text style={style.title}>
@ -210,7 +216,7 @@ export const CallsCustomMessage = ({
const button = alreadyInTheCall ? (
<TouchableOpacity
style={[style.callButton, style.leaveCallButton]}
onPress={leaveHandler}
onPress={leaveCallHandler}
>
<CompassIcon
name='phone-hangup'

View file

@ -6,7 +6,7 @@ import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {CallsCustomMessage} from '@calls/components/calls_custom_message/calls_custom_message';
import {observeIsCallLimitRestricted} from '@calls/observers';
import {observeEndCallDetails, observeIsCallLimitRestricted} from '@calls/observers';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {Preferences} from '@constants';
import {getDisplayNamePreferenceAsBool} from '@helpers/api/preference';
@ -52,6 +52,7 @@ const enhanced = withObservables(['post'], ({serverUrl, post, database}: OwnProp
limitRestrictedInfo: observeIsCallLimitRestricted(database, serverUrl, post.channelId),
ccChannelId,
joiningChannelId,
...observeEndCallDetails(),
};
});

View file

@ -4,7 +4,7 @@
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {leaveCall} from '@calls/actions';
import {leaveCallConfirmation} from '@calls/actions/calls';
import {leaveAndJoinWithAlert, showLimitRestrictedAlert} from '@calls/alerts';
import {useTryCallsFunction} from '@calls/hooks';
import Loading from '@components/loading';
@ -23,6 +23,9 @@ export interface Props {
alreadyInCall: boolean;
dismissChannelInfo: () => void;
limitRestrictedInfo: LimitRestrictedInfo;
otherParticipants: boolean;
isAdmin: boolean;
isHost: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
@ -53,6 +56,9 @@ const ChannelInfoStartButton = ({
alreadyInCall,
dismissChannelInfo,
limitRestrictedInfo,
otherParticipants,
isAdmin,
isHost,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
@ -66,8 +72,7 @@ const ChannelInfoStartButton = ({
const toggleJoinLeave = useCallback(async () => {
if (alreadyInCall) {
leaveCall();
dismissChannelInfo();
await leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, channelId, dismissChannelInfo);
} else if (isLimitRestricted) {
showLimitRestrictedAlert(limitRestrictedInfo, intl);
dismissChannelInfo();
@ -79,7 +84,7 @@ const ChannelInfoStartButton = ({
setConnecting(false);
dismissChannelInfo();
}
}, [isLimitRestricted, alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, isACallInCurrentChannel]);
}, [isLimitRestricted, alreadyInCall, dismissChannelInfo, intl, serverUrl, channelId, isACallInCurrentChannel, otherParticipants]);
const [tryJoin, msgPostfix] = useTryCallsFunction(toggleJoinLeave);

View file

@ -6,7 +6,7 @@ import {of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import ChannelInfoStartButton from '@calls/components/channel_info_start/channel_info_start_button';
import {observeIsCallLimitRestricted} from '@calls/observers';
import {observeEndCallDetails, observeIsCallLimitRestricted} from '@calls/observers';
import {observeChannelsWithCalls, observeCurrentCall} from '@calls/state';
import type {WithDatabaseArgs} from '@typings/database/database';
@ -33,6 +33,7 @@ const enhanced = withObservables([], ({serverUrl, channelId, database}: EnhanceP
confirmToJoin,
alreadyInCall,
limitRestrictedInfo: observeIsCallLimitRestricted(database, serverUrl, channelId),
...observeEndCallDetails(),
};
});

View file

@ -5,7 +5,8 @@ import React, {useCallback, useMemo} from 'react';
import {useIntl} from 'react-intl';
import {View, Text, Pressable, Platform} from 'react-native';
import {leaveCall, muteMyself, unmuteMyself} from '@calls/actions';
import {muteMyself, unmuteMyself} from '@calls/actions';
import {leaveCallConfirmation} from '@calls/actions/calls';
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
import CallAvatar from '@calls/components/call_avatar';
import CallDuration from '@calls/components/call_duration';
@ -34,6 +35,9 @@ type Props = {
teammateNameDisplay: string;
micPermissionsGranted: boolean;
threadScreen?: boolean;
otherParticipants: boolean;
isAdmin: boolean;
isHost: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => {
@ -139,6 +143,9 @@ const CurrentCallBar = ({
teammateNameDisplay,
micPermissionsGranted,
threadScreen,
otherParticipants,
isAdmin,
isHost,
}: Props) => {
const theme = useTheme();
const serverUrl = useServerUrl();
@ -168,8 +175,8 @@ const CurrentCallBar = ({
}, [formatMessage, threadScreen]);
const leaveCallHandler = useCallback(() => {
leaveCall();
}, []);
leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, currentCall?.channelId || '');
}, [intl, otherParticipants, isAdmin, isHost, serverUrl, currentCall?.channelId]);
const mySession = currentCall?.sessions[currentCall.mySessionId];
@ -210,7 +217,6 @@ const CurrentCallBar = ({
// The user should receive an alert if all of the following conditions apply:
// - Recording has started and recording has not ended.
const isHost = Boolean(currentCall?.hostId === mySession?.userId);
if (currentCall?.recState?.start_at && !currentCall?.recState?.end_at) {
recordingAlert(isHost, EnableTranscriptions, intl);
}

View file

@ -5,7 +5,7 @@ import {withObservables} from '@nozbe/watermelondb/react';
import {combineLatest, of as of$} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCurrentSessionsDict} from '@calls/observers';
import {observeCurrentSessionsDict, observeEndCallDetails} from '@calls/observers';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import DatabaseManager from '@database/manager';
import {observeChannel} from '@queries/servers/channel';
@ -46,6 +46,7 @@ const enhanced = withObservables([], () => {
sessionsDict: observeCurrentSessionsDict(),
teammateNameDisplay,
micPermissionsGranted,
...observeEndCallDetails(),
};
});

View file

@ -5,6 +5,7 @@ import {DeviceEventEmitter} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import {leaveCall, muteMyself, unraiseHand} from '@calls/actions';
import {createCallAndAddToIds} from '@calls/actions/calls';
import {hostRemovedErr} from '@calls/errors';
import {
callEnded,
@ -13,6 +14,7 @@ import {
getCurrentCall,
receivedCaption,
removeIncomingCall,
setCallForChannel,
setCallScreenOff,
setCallScreenOn,
setCaptioningState,
@ -38,6 +40,8 @@ import type {
CallJobState,
CallJobStateData,
CallStartData,
CallState,
CallStateData,
EmptyData,
LiveCaptionData,
UserConnectedData,
@ -240,3 +244,11 @@ export const handleHostRemoved = async (serverUrl: string, msg: WebSocketMessage
leaveCall(hostRemovedErr);
};
export const handleCallState = (serverUrl: string, msg: WebSocketMessage<CallStateData>) => {
const callState: CallState = JSON.parse(msg.data.call);
const call = createCallAndAddToIds(msg.data.channel_id, callState);
setCallForChannel(serverUrl, msg.data.channel_id, call);
};

View file

@ -10,7 +10,8 @@ import Permissions from 'react-native-permissions';
import {initializeVoiceTrack} from '@calls/actions/calls';
import {
getCallsConfig, getCurrentCall,
getCallsConfig,
getCurrentCall,
setMicPermissionsGranted,
useCallsState,
useChannelsWithCalls,
@ -18,6 +19,7 @@ import {
useGlobalCallsState,
useIncomingCalls,
} from '@calls/state';
import {type CallSession} from '@calls/types/calls';
import {errorAlert, isHostControlsAllowed} from '@calls/utils';
import {Screens} from '@constants';
import {
@ -37,7 +39,6 @@ import {openAsBottomSheet} from '@screens/navigation';
import {getFullErrorMessage} from '@utils/errors';
import {isSystemAdmin} from '@utils/user';
import type {CallSession} from '@calls/types/calls';
import type {Client} from '@client/rest';
export const useTryCallsFunction = (fn: () => void) => {

View file

@ -17,6 +17,7 @@ import {observeConfigValue, observeLicense} from '@queries/servers/system';
import {queryUsersById} from '@queries/servers/user';
import UserModel from '@typings/database/models/servers/user';
import {isMinimumServerVersion} from '@utils/helpers';
import {isSystemAdmin} from '@utils/user';
import type {CallSession} from '@calls/types/calls';
import type {Database} from '@nozbe/watermelondb';
@ -137,3 +138,25 @@ export const observeCallStateInChannel = (serverUrl: string, database: Database,
showIncomingCalls,
};
};
export const observeEndCallDetails = () => {
const cc = observeCurrentCall();
const otherParticipants = cc.pipe(
switchMap((call) => of$(Object.keys(call?.sessions || {}).length > 1)),
distinctUntilChanged(),
);
const isAdmin = cc.pipe(
switchMap((call) => of$(isSystemAdmin(call?.sessions[call?.mySessionId || '']?.userModel?.roles || ''))),
distinctUntilChanged(),
);
const isHost = cc.pipe(
switchMap((call) => of$(call?.hostId === call?.myUserId)),
distinctUntilChanged(),
);
return {
otherParticipants,
isAdmin,
isHost,
};
};

View file

@ -22,8 +22,8 @@ import {Navigation} from 'react-native-navigation';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import {RTCView} from 'react-native-webrtc';
import {leaveCall, muteMyself, unmuteMyself} from '@calls/actions';
import {startCallRecording, stopCallRecording} from '@calls/actions/calls';
import {muteMyself, unmuteMyself} from '@calls/actions';
import {leaveCallConfirmation, startCallRecording, stopCallRecording} from '@calls/actions/calls';
import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} from '@calls/alerts';
import {AudioDeviceButton} from '@calls/components/audio_device_button';
import CallDuration from '@calls/components/call_duration';
@ -88,6 +88,9 @@ export type Props = {
fromThreadScreen?: boolean;
displayName?: string;
isOwnDirectMessage: boolean;
otherParticipants: boolean;
isAdmin: boolean;
isHost: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: CallsTheme) => ({
@ -311,6 +314,9 @@ const CallScreen = ({
fromThreadScreen,
displayName,
isOwnDirectMessage,
otherParticipants,
isAdmin,
isHost,
}: Props) => {
const intl = useIntl();
const theme = useTheme();
@ -380,9 +386,8 @@ const CallScreen = ({
}, []);
const leaveCallHandler = useCallback(() => {
popTopScreen();
leaveCall();
}, []);
leaveCallConfirmation(intl, otherParticipants, isAdmin, isHost, serverUrl, currentCall?.channelId || '', popTopScreen);
}, [intl, otherParticipants, isAdmin, isHost, serverUrl, currentCall?.channelId]);
const muteUnmuteHandler = useCallback(() => {
if (mySession?.muted) {
@ -453,7 +458,6 @@ const CallScreen = ({
// The user should receive a recording alert if all of the following conditions apply:
// - Recording has started, recording has not ended
const isHost = Boolean(currentCall?.hostId === mySession?.userId);
const recording = Boolean(currentCall?.recState?.start_at && !currentCall.recState.end_at);
if (recording) {
recordingAlert(isHost, EnableTranscriptions, intl);

View file

@ -5,7 +5,7 @@ import {withDatabase, withObservables} from '@nozbe/watermelondb/react';
import {of as of$, combineLatestWith} from 'rxjs';
import {distinctUntilChanged, switchMap} from 'rxjs/operators';
import {observeCallDatabase, observeCurrentSessionsDict} from '@calls/observers';
import {observeCallDatabase, observeCurrentSessionsDict, observeEndCallDetails} from '@calls/observers';
import CallScreen from '@calls/screens/call_screen/call_screen';
import {observeCurrentCall, observeGlobalCallsState} from '@calls/state';
import {General} from '@constants';
@ -53,6 +53,7 @@ const enhanced = withObservables([], ({database}: WithDatabaseArgs) => {
teammateNameDisplay,
displayName,
isOwnDirectMessage,
...observeEndCallDetails(),
};
});

View file

@ -184,15 +184,16 @@ export const removeIncomingCall = (serverUrl: string, callId: string, channelId?
setCallsState(serverUrl, {...callsState, calls: nextCalls});
};
export const setCallForChannel = (serverUrl: string, channelId: string, enabled?: boolean, call?: Call) => {
export const setCallForChannel = (serverUrl: string, channelId: string, call?: Call, enabled?: boolean) => {
const callsState = getCallsState(serverUrl);
let nextEnabled = callsState.enabled;
if (typeof enabled !== 'undefined') {
nextEnabled = {...callsState.enabled, [channelId]: enabled};
}
const nextCalls = {...callsState.calls};
let nextCalls = callsState.calls;
if (call) {
nextCalls = {...callsState.calls};
nextCalls[channelId] = call;
// In case we got a complete update on the currentCall

View file

@ -249,3 +249,9 @@ export type HostControlsLowerHandMsgData = HostControlsMsgData & {
call_id: string;
host_id: string;
}
export enum EndCallReturn {
Cancel,
LeaveCall,
EndCall,
}

View file

@ -445,6 +445,7 @@
"mobile.calls_call_ended": "Call ended",
"mobile.calls_call_screen": "Call",
"mobile.calls_call_thread": "Call Thread",
"mobile.calls_cancel": "Cancel",
"mobile.calls_captions": "Captions",
"mobile.calls_captions_turned_on": "Live captions turned on",
"mobile.calls_disable": "Disable calls",
@ -463,6 +464,9 @@
"mobile.calls_hide_cc": "Hide live captions",
"mobile.calls_host": "host",
"mobile.calls_host_controls": "Host controls",
"mobile.calls_host_end_confirm": "End call for everyone",
"mobile.calls_host_leave_confirm": "Leave call",
"mobile.calls_host_leave_title": "Are you sure you want to leave this call?",
"mobile.calls_host_rec": "You are recording this meeting. Consider letting everyone know that this meeting is being recorded.",
"mobile.calls_host_rec_error": "Please try to record again. You can also contact your system admin for troubleshooting help.",
"mobile.calls_host_rec_error_title": "Something went wrong with the recording",