From 0f8798ca55d37fa794a0b02aac0302d0eb2378a3 Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 30 Nov 2022 11:20:00 -0500 Subject: [PATCH] MM-48611 - Calls: Calls recording host controls and UI (#6787) * implement recording permissions and UI * fix bug when refusing recording while in call thread from call screen * host controls and UI for recording * button tweak, and fixing a i18n string issue * PR comment * backwards compat & don't show rec controls if not enabled --- app/products/calls/actions/calls.ts | 35 ++++++ app/products/calls/alerts.ts | 114 +++++++++++++----- app/products/calls/client/rest.ts | 24 +++- app/products/calls/components/calls_badge.tsx | 26 +++- .../current_call_bar/current_call_bar.tsx | 17 ++- .../calls/screens/call_screen/call_screen.tsx | 105 ++++++++++++---- app/products/calls/state/actions.test.ts | 43 +------ app/products/calls/state/actions.ts | 19 +-- app/products/calls/types/calls.ts | 2 + app/products/calls/utils.test.ts | 13 +- assets/base/i18n/en.json | 9 +- 11 files changed, 284 insertions(+), 123 deletions(-) diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 7e0da4e9d..55b70813f 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -5,6 +5,7 @@ import InCallManager from 'react-native-incall-manager'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {fetchUsersByIds} from '@actions/remote/user'; +import {needsRecordingWillBePostedAlert} from '@calls/alerts'; import { getCallsConfig, getCallsState, @@ -27,6 +28,7 @@ import {getChannelById} from '@queries/servers/channel'; import {queryPreferencesByCategoryAndName} from '@queries/servers/preference'; import {getConfig, getLicense} from '@queries/servers/system'; import {getCurrentUser, getUserById} from '@queries/servers/user'; +import {logWarning} from '@utils/log'; import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/user'; import {newConnection} from '../connection/connection'; @@ -37,6 +39,7 @@ import type { CallParticipant, CallReactionEmoji, CallsConnection, + RecordingState, ServerCallState, ServerChannelState, } from '@calls/types/calls'; @@ -381,3 +384,35 @@ export const endCall = async (serverUrl: string, channelId: string) => { return data; }; + +export const startCallRecording = async (serverUrl: string, callId: string) => { + const client = NetworkManager.getClient(serverUrl); + + let data: ApiResp | RecordingState; + try { + data = await client.startCallRecording(callId); + } catch (error) { + await forceLogoutIfNecessary(serverUrl, error as ClientError); + logWarning('start call recording returned:', error); + return error; + } + + return data; +}; + +export const stopCallRecording = async (serverUrl: string, callId: string) => { + needsRecordingWillBePostedAlert(); + + const client = NetworkManager.getClient(serverUrl); + + let data: ApiResp | RecordingState; + try { + data = await client.stopCallRecording(callId); + } catch (error) { + await forceLogoutIfNecessary(serverUrl, error as ClientError); + logWarning('stop call recording returned:', error); + return error; + } + + return data; +}; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index f34ccc1de..36db5e70e 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -5,7 +5,7 @@ import {Alert} from 'react-native'; import {Navigation} from 'react-native-navigation'; import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions'; -import {setMicPermissionsGranted, setRecAcknowledged} from '@calls/state'; +import {setMicPermissionsGranted} from '@calls/state'; import {errorAlert} from '@calls/utils'; import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; @@ -19,6 +19,9 @@ import type {IntlShape} from 'react-intl'; // Only allow one recording alert per call. let recordingAlertLock = false; +// Only unlock if/when the user starts a recording. +let recordingWillBePostedLock = true; + export const showLimitRestrictedAlert = (maxParticipants: number, intl: IntlShape) => { const title = intl.formatMessage({ id: 'mobile.calls_participant_limit_title', @@ -81,6 +84,7 @@ export const leaveAndJoinWithAlert = ( id: 'mobile.post.cancel', defaultMessage: 'Cancel', }), + style: 'destructive', }, { text: formatMessage({ @@ -115,6 +119,7 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea } recordingAlertLock = false; + recordingWillBePostedLock = true; // only unlock if/when the user stops a recording. const hasPermission = await hasMicrophonePermission(); setMicPermissionsGranted(hasPermission); @@ -134,7 +139,7 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea } }; -export const recordingAlert = (intl: IntlShape) => { +export const recordingAlert = (isHost: boolean, intl: IntlShape) => { if (recordingAlertLock) { return; } @@ -142,42 +147,89 @@ export const recordingAlert = (intl: IntlShape) => { const {formatMessage} = intl; + const participantTitle = formatMessage({ + id: 'mobile.calls_participant_rec_title', + defaultMessage: 'Recording is in progress', + }); + const hostTitle = formatMessage({ + id: 'mobile.calls_host_rec_title', + defaultMessage: 'You are recording', + }); const participantMessage = formatMessage({ id: 'mobile.calls_participant_rec', defaultMessage: 'The host has started recording this meeting. By staying in the meeting you give consent to being recorded.', }); + const hostMessage = formatMessage({ + id: 'mobile.calls_host_rec', + defaultMessage: 'You are recording this meeting. Consider letting everyone know that this meeting is being recorded.', + }); + + const participantButtons = [ + { + text: formatMessage({ + id: 'mobile.calls_leave', + defaultMessage: 'Leave', + }), + onPress: async () => { + leaveCall(); + + // Need to pop the call screen, if it's somewhere in the stack. + await dismissAllModals(); + if (NavigationStore.getNavigationComponents().includes(Screens.CALL)) { + await dismissAllModalsAndPopToScreen(Screens.CALL, 'Call'); + Navigation.pop(Screens.CALL).catch(() => null); + } + }, + style: 'destructive', + }, + { + text: formatMessage({ + id: 'mobile.calls_okay', + defaultMessage: 'Okay', + }), + style: 'default', + }, + ]; + const hostButton = [{ + text: formatMessage({ + id: 'mobile.calls_dismiss', + defaultMessage: 'Dismiss', + }), + }]; + + Alert.alert( + isHost ? hostTitle : participantTitle, + isHost ? hostMessage : participantMessage, + isHost ? hostButton : participantButtons, + ); +}; + +export const needsRecordingWillBePostedAlert = () => { + recordingWillBePostedLock = false; +}; + +export const recordingWillBePostedAlert = (intl: IntlShape) => { + if (recordingWillBePostedLock) { + return; + } + recordingWillBePostedLock = true; + + const {formatMessage} = intl; Alert.alert( formatMessage({ - id: 'mobile.calls_participant_rec_title', - defaultMessage: 'Recording is in progress', + id: 'mobile.calls_host_rec_stopped_title', + defaultMessage: 'Recording has stopped. Processing...', }), - participantMessage, - [ - { - text: formatMessage({ - id: 'mobile.calls_leave', - defaultMessage: 'Leave', - }), - onPress: async () => { - leaveCall(); - - // Need to pop the call screen, if it's somewhere in the stack. - await dismissAllModals(); - if (NavigationStore.getNavigationComponents().includes(Screens.CALL)) { - await dismissAllModalsAndPopToScreen(Screens.CALL, 'Call'); - Navigation.pop(Screens.CALL).catch(() => null); - } - }, - style: 'cancel', - }, - { - text: formatMessage({ - id: 'mobile.calls_okay', - defaultMessage: 'Okay', - }), - onPress: () => setRecAcknowledged(), - }, - ], + formatMessage({ + id: 'mobile.calls_host_rec_stopped', + defaultMessage: 'You can find the recording in this call\'s chat thread once it\'s finished processing.', + }), + [{ + text: formatMessage({ + id: 'mobile.calls_dismiss', + defaultMessage: 'Dismiss', + }), + }], ); }; diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 471f7b966..3403789b9 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -1,7 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ServerChannelState, ServerCallsConfig, ApiResp, ICEServersConfigs} from '@calls/types/calls'; +import type { + ServerChannelState, + ServerCallsConfig, + ApiResp, + ICEServersConfigs, + RecordingState, +} from '@calls/types/calls'; export interface ClientCallsMix { getEnabled: () => Promise; @@ -11,6 +17,8 @@ export interface ClientCallsMix { enableChannelCalls: (channelId: string, enable: boolean) => Promise; endCall: (channelId: string) => Promise; genTURNCredentials: () => Promise; + startCallRecording: (callId: string) => Promise; + stopCallRecording: (callId: string) => Promise; } const ClientCalls = (superclass: any) => class extends superclass { @@ -67,6 +75,20 @@ const ClientCalls = (superclass: any) => class extends superclass { {method: 'get'}, ); }; + + startCallRecording = async (callID: string) => { + return this.doFetch( + `${this.getCallsRoute()}/calls/${callID}/recording/start`, + {method: 'post'}, + ); + }; + + stopCallRecording = async (callID: string) => { + return this.doFetch( + `${this.getCallsRoute()}/calls/${callID}/recording/stop`, + {method: 'post'}, + ); + }; }; export default ClientCalls; diff --git a/app/products/calls/components/calls_badge.tsx b/app/products/calls/components/calls_badge.tsx index 848461c9e..58ec37c6c 100644 --- a/app/products/calls/components/calls_badge.tsx +++ b/app/products/calls/components/calls_badge.tsx @@ -6,6 +6,7 @@ import {StyleSheet, View} from 'react-native'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; +import Loading from '@components/loading'; import {typography} from '@utils/typography'; const styles = StyleSheet.create({ @@ -16,12 +17,19 @@ const styles = StyleSheet.create({ justifyContent: 'center', borderRadius: 4, }, + loading: { + paddingLeft: 8, + paddingRight: 8, + color: 'white', + height: 34, + backgroundColor: 'rgba(255, 255, 255, 0.16)', + }, recording: { paddingLeft: 8, paddingRight: 8, - backgroundColor: '#D24B4E', color: 'white', height: 34, + backgroundColor: '#D24B4E', }, text: { color: 'white', @@ -45,6 +53,7 @@ const styles = StyleSheet.create({ }); export enum CallsBadgeType { + Waiting, Rec, Host, } @@ -54,9 +63,11 @@ interface Props { } const CallsBadge = ({type}: Props) => { + const isLoading = type === CallsBadgeType.Waiting; const isRec = type === CallsBadgeType.Rec; + const isParticipant = !(isLoading || isRec); - const text = isRec ? ( + const text = isLoading || isRec ? ( { /> ); + const containerStyles = [ + styles.container, + isLoading && styles.loading, + isRec && styles.recording, + isParticipant && styles.participant, + ]; return ( - + + { + isLoading && + } { isRec && ({ overflow: 'hidden', }, hangUpIcon: { - backgroundColor: '#D24B4E', + backgroundColor: Preferences.THEMES.denim.dndIndicator, }, screenShareImage: { flex: 7, @@ -241,6 +244,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ unavailableText: { color: changeOpacity(theme.sidebarText, 0.32), }, + denimDND: { + color: Preferences.THEMES.denim.dndIndicator, + }, })); const CallScreen = ({ @@ -255,6 +261,8 @@ const CallScreen = ({ const theme = useTheme(); const insets = useSafeAreaInsets(); const {width, height} = useWindowDimensions(); + const serverUrl = useServerUrl(); + const {EnableRecordings} = useCallsConfig(serverUrl); usePermissionsChecker(micPermissionsGranted); const [showControlsInLandscape, setShowControlsInLandscape] = useState(false); const [showReactions, setShowReactions] = useState(false); @@ -264,7 +272,11 @@ const CallScreen = ({ const showControls = !isLandscape || showControlsInLandscape; const myParticipant = currentCall?.participants[currentCall.myUserId]; const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed; - const chatThreadTitle = intl.formatMessage({id: 'mobile.calls_chat_thread', defaultMessage: 'Chat thread'}); + + const callThreadOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Call Thread'}); + const recordOptionTitle = intl.formatMessage({id: 'mobile.calls_record', defaultMessage: 'Record'}); + const stopRecordingOptionTitle = intl.formatMessage({id: 'mobile.calls_stop_recording', defaultMessage: 'Stop Recording'}); + const openChannelOptionTitle = intl.formatMessage({id: 'mobile.calls_call_thread', defaultMessage: 'Open Channel'}); useEffect(() => { mergeNavigationOptions('Call', { @@ -302,6 +314,26 @@ const CallScreen = ({ setShowControlsInLandscape(!showControlsInLandscape); }, [showControlsInLandscape]); + const startRecording = useCallback(async () => { + Keyboard.dismiss(); + await dismissBottomSheet(); + if (!currentCall) { + return; + } + + await startCallRecording(currentCall.serverUrl, currentCall.channelId); + }, [currentCall?.channelId, currentCall?.serverUrl]); + + const stopRecording = useCallback(async () => { + Keyboard.dismiss(); + await dismissBottomSheet(); + if (!currentCall) { + return; + } + + await stopCallRecording(currentCall.serverUrl, currentCall.channelId); + }, [currentCall?.channelId, currentCall?.serverUrl]); + const switchToThread = useCallback(async () => { Keyboard.dismiss(); await dismissBottomSheet(); @@ -311,7 +343,7 @@ const CallScreen = ({ const activeUrl = await DatabaseManager.getActiveServerUrl(); if (activeUrl === currentCall.serverUrl) { - await dismissAllModalsAndPopToScreen(Screens.THREAD, chatThreadTitle, {rootId: currentCall.threadId}); + await dismissAllModalsAndPopToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId}); return; } @@ -323,30 +355,69 @@ const CallScreen = ({ } await DatabaseManager.setActiveServerDatabase(currentCall.serverUrl); await appEntry(currentCall.serverUrl, Date.now()); - await goToScreen(Screens.THREAD, chatThreadTitle, {rootId: currentCall.threadId}); - }, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, chatThreadTitle]); + await goToScreen(Screens.THREAD, callThreadOptionTitle, {rootId: currentCall.threadId}); + }, [currentCall?.serverUrl, currentCall?.threadId, fromThreadScreen, componentId, callThreadOptionTitle]); - const showOtherActions = useCallback(() => { + // 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 === myParticipant?.id); + const recording = Boolean(currentCall?.recState?.start_at && !currentCall.recState.end_at); + if (recording) { + recordingAlert(isHost, intl); + } + + // The user should receive a recording finished alert if all of the following conditions apply: + // - Is the host, recording has started, and recording has ended + if (isHost && currentCall?.recState?.start_at && currentCall.recState.end_at) { + recordingWillBePostedAlert(intl); + } + + // The user should see the loading only if: + // - Recording has been initialized, recording has not been started, and recording has not ended + const waitingForRecording = Boolean(currentCall?.recState?.init_at && !currentCall.recState.start_at && !currentCall.recState.end_at && isHost); + + const showOtherActions = useCallback(async () => { const renderContent = () => { return ( + { + isHost && EnableRecordings && !(waitingForRecording || recording) && + + } + { + isHost && EnableRecordings && (waitingForRecording || recording) && + + } ); }; - bottomSheet({ + const items = isHost && EnableRecordings ? 3 : 2; + await bottomSheet({ closeButtonId: 'close-other-actions', renderContent, - snapPoints: [bottomSheetSnapPoint(2, ITEM_HEIGHT, insets.bottom), 10], + snapPoints: [bottomSheetSnapPoint(items, ITEM_HEIGHT, insets.bottom), 10], title: intl.formatMessage({id: 'post.options.title', defaultMessage: 'Options'}), theme, }); - }, [insets, intl, theme]); + }, [insets, intl, theme, isHost, EnableRecordings, waitingForRecording, recording, startRecording, + recordOptionTitle, stopRecording, stopRecordingOptionTitle, style, switchToThread, callThreadOptionTitle, + openChannelOptionTitle]); useEffect(() => { const listener = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => { @@ -369,15 +440,6 @@ const CallScreen = ({ return null; } - // The user should receive an alert if all of the following conditions apply: - // - Recording has started. - // - Recording has not ended. - // - The alert has not been dismissed already. - const recording = Boolean(currentCall.recState?.start_at && !currentCall.recState?.end_at); - if (recording && !currentCall.recAcknowledged) { - recordingAlert(intl); - } - let screenShareView = null; if (currentCall.screenShareURL && currentCall.screenOn) { screenShareView = ( @@ -464,6 +526,7 @@ const CallScreen = ({ + {waitingForRecording && } {recording && } { last_retrieved_at: 123, sku_short_name: License.SKU_SHORT_NAME.Professional, MaxCallParticipants: 8, + EnableRecordings: true, }; // setup @@ -955,44 +956,6 @@ describe('useCallsState', () => { assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); }); - it('setRecAcknowledged', () => { - const initialCallsState = { - ...DefaultCallsState, - calls: {'channel-1': call1, 'channel-2': call2}, - }; - const initialCurrentCallState: CurrentCall = { - ...DefaultCurrentCall, - connected: true, - serverUrl: 'server1', - myUserId: 'myUserId', - ...call1, - }; - const expectedCurrentCallState: CurrentCall = { - ...DefaultCurrentCall, - connected: true, - serverUrl: 'server1', - myUserId: 'myUserId', - ...call1, - recAcknowledged: true, - }; - - // setup - const {result} = renderHook(() => { - return [useCallsState('server1'), useCurrentCall()]; - }); - act(() => { - setCallsState('server1', initialCallsState); - setCurrentCall(initialCurrentCallState); - }); - assert.deepEqual(result.current[0], initialCallsState); - assert.deepEqual(result.current[1], initialCurrentCallState); - - // test - act(() => setRecAcknowledged()); - assert.deepEqual(result.current[0], initialCallsState); - assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); - }); - it('setHost', () => { const initialCallsState = { ...DefaultCallsState, diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index ad0c8942e..9d0d1f10c 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -212,11 +212,9 @@ export const callStarted = (serverUrl: string, call: Call) => { return; } - const nextCurrentCall = { + const nextCurrentCall: CurrentCall = { ...currentCall, - startTime: call.startTime, - threadId: call.threadId, - ownerId: call.ownerId, + ...call, }; setCurrentCall(nextCurrentCall); }; @@ -519,19 +517,6 @@ export const setRecordingState = (serverUrl: string, channelId: string, recState setCurrentCall(nextCurrentCall); }; -export const setRecAcknowledged = () => { - const currentCall = getCurrentCall(); - if (!currentCall) { - return; - } - - const nextCurrentCall: CurrentCall = { - ...currentCall, - recAcknowledged: true, - }; - setCurrentCall(nextCurrentCall); -}; - export const setHost = (serverUrl: string, channelId: string, hostId: string) => { const callsState = getCallsState(serverUrl); if (!callsState.calls[channelId]) { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 08bb6b322..fd9c4d144 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -124,6 +124,7 @@ export type ServerCallsConfig = { NeedsTURNCredentials: boolean; sku_short_name: string; MaxCallParticipants: number; + EnableRecordings: boolean; } export type CallsConfig = ServerCallsConfig & { @@ -141,6 +142,7 @@ export const DefaultCallsConfig: CallsConfig = { last_retrieved_at: 0, sku_short_name: '', MaxCallParticipants: 0, + EnableRecordings: false, }; export type ICEServersConfigs = Array; diff --git a/app/products/calls/utils.test.ts b/app/products/calls/utils.test.ts index 7a74ea112..69046c25e 100644 --- a/app/products/calls/utils.test.ts +++ b/app/products/calls/utils.test.ts @@ -3,13 +3,15 @@ import assert from 'assert'; +import {CallsConfig} from '@calls/types/calls'; import {License} from '@constants'; import {getICEServersConfigs} from './utils'; describe('getICEServersConfigs', () => { it('backwards compatible case, no ICEServersConfigs present', () => { - const config = { + const config: CallsConfig = { + pluginEnabled: true, ICEServers: ['stun:stun.example.com:3478'], AllowEnableCalls: true, DefaultEnabled: true, @@ -17,6 +19,7 @@ describe('getICEServersConfigs', () => { last_retrieved_at: 0, sku_short_name: License.SKU_SHORT_NAME.Professional, MaxCallParticipants: 8, + EnableRecordings: true, }; const iceConfigs = getICEServersConfigs(config); @@ -28,7 +31,8 @@ describe('getICEServersConfigs', () => { }); it('ICEServersConfigs set', () => { - const config = { + const config: CallsConfig = { + pluginEnabled: true, ICEServersConfigs: [ { urls: ['stun:stun.example.com:3478'], @@ -43,6 +47,7 @@ describe('getICEServersConfigs', () => { last_retrieved_at: 0, sku_short_name: License.SKU_SHORT_NAME.Professional, MaxCallParticipants: 8, + EnableRecordings: true, }; const iceConfigs = getICEServersConfigs(config); @@ -57,7 +62,8 @@ describe('getICEServersConfigs', () => { }); it('Both ICEServers and ICEServersConfigs set', () => { - const config = { + const config: CallsConfig = { + pluginEnabled: true, ICEServers: ['stun:stuna.example.com:3478'], ICEServersConfigs: [ { @@ -73,6 +79,7 @@ describe('getICEServersConfigs', () => { last_retrieved_at: 0, sku_short_name: License.SKU_SHORT_NAME.Professional, MaxCallParticipants: 8, + EnableRecordings: true, }; const iceConfigs = getICEServersConfigs(config); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c5841f1f2..377b0ce79 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -361,9 +361,10 @@ "mobile.announcement_banner.title": "Announcement", "mobile.calls_call_ended": "Call ended", "mobile.calls_call_screen": "Call", - "mobile.calls_chat_thread": "Chat thread", + "mobile.calls_call_thread": "Open Channel", "mobile.calls_current_call": "Current call", "mobile.calls_disable": "Disable calls", + "mobile.calls_dismiss": "Dismiss", "mobile.calls_enable": "Enable calls", "mobile.calls_end_call_title": "End call", "mobile.calls_end_msg_channel": "Are you sure you want to end a call with {numParticipants} participants in {displayName}?", @@ -375,6 +376,10 @@ "mobile.calls_error_message": "Error: {error}", "mobile.calls_error_title": "Error", "mobile.calls_host": "host", + "mobile.calls_host_rec": "You are recording this meeting. Consider letting everyone know that this meeting is being recorded.", + "mobile.calls_host_rec_stopped": "You can find the recording in this call's chat thread once it's finished processing.", + "mobile.calls_host_rec_stopped_title": "Recording has stopped. Processing...", + "mobile.calls_host_rec_title": "You are recording", "mobile.calls_join_call": "Join call", "mobile.calls_lasted": "Lasted {duration}", "mobile.calls_leave": "Leave", @@ -399,9 +404,11 @@ "mobile.calls_raise_hand": "Raise hand", "mobile.calls_react": "React", "mobile.calls_rec": "rec", + "mobile.calls_record": "Record", "mobile.calls_see_logs": "See server logs", "mobile.calls_speaker": "Speaker", "mobile.calls_start_call": "Start Call", + "mobile.calls_stop_recording": "Stop Recording", "mobile.calls_unmute": "Unmute", "mobile.calls_viewing_screen": "You are viewing {name}'s screen", "mobile.calls_you": "(you)",