From 6660c134f5221d17a1284e67c07a62c9a008787a Mon Sep 17 00:00:00 2001 From: Christopher Poile Date: Wed, 30 Nov 2022 10:15:20 -0500 Subject: [PATCH] MM-48072 - Calls: Implement recording permissions and UI for participants (#6784) * implement recording permissions and UI * fix bug when refusing recording while in call thread from call screen * merge conflicts --- app/actions/websocket/index.ts | 8 + app/constants/websocket.ts | 2 + app/products/calls/actions/calls.test.ts | 1 + app/products/calls/actions/calls.ts | 2 + app/products/calls/alerts.ts | 60 ++++++- app/products/calls/components/calls_badge.tsx | 88 ++++++++++ .../current_call_bar/current_call_bar.tsx | 12 +- .../connection/websocket_event_handlers.ts | 11 ++ .../calls/screens/call_screen/call_screen.tsx | 27 ++- app/products/calls/state/actions.test.ts | 155 ++++++++++++++++++ app/products/calls/state/actions.ts | 63 ++++++- app/products/calls/types/calls.ts | 20 ++- assets/base/i18n/en.json | 5 + 13 files changed, 438 insertions(+), 16 deletions(-) create mode 100644 app/products/calls/components/calls_badge.tsx diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 109132be4..5ba00ca07 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -11,6 +11,8 @@ import { handleCallChannelDisabled, handleCallChannelEnabled, handleCallEnded, + handleCallHostChanged, + handleCallRecordingState, handleCallScreenOff, handleCallScreenOn, handleCallStarted, @@ -397,6 +399,12 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { case WebsocketEvents.CALLS_USER_REACTED: handleCallUserReacted(serverUrl, msg); break; + case WebsocketEvents.CALLS_RECORDING_STATE: + handleCallRecordingState(serverUrl, msg); + break; + case WebsocketEvents.CALLS_HOST_CHANGED: + handleCallHostChanged(serverUrl, msg); + break; case WebsocketEvents.GROUP_RECEIVED: handleGroupReceivedEvent(serverUrl, msg); diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index b92e26e64..465cd55aa 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -67,6 +67,8 @@ const WebsocketEvents = { CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`, CALLS_USER_UNRAISE_HAND: `custom_${Calls.PluginId}_user_unraise_hand`, CALLS_USER_REACTED: `custom_${Calls.PluginId}_user_reacted`, + CALLS_RECORDING_STATE: `custom_${Calls.PluginId}_call_recording_state`, + CALLS_HOST_CHANGED: `custom_${Calls.PluginId}_call_host_changed`, GROUP_RECEIVED: 'received_group', GROUP_MEMBER_ADD: 'group_member_add', GROUP_MEMBER_DELETE: 'group_member_delete', diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 4b298cec7..672787e82 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -89,6 +89,7 @@ const addFakeCall = (serverUrl: string, channelId: string) => { screenOn: '', threadId: 'abcd1234567', ownerId: 'xohi8cki9787fgiryne716u84o', + hostId: 'xohi8cki9787fgiryne716u84o', } as Call; act(() => { State.setCallsState(serverUrl, {serverUrl, myUserId: 'myUserId', calls: {}, enabled: {}}); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index e30aea07c..7e0da4e9d 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -163,6 +163,8 @@ const createCallAndAddToIds = (channelId: string, call: ServerCallState, ids: Se screenOn: call.screen_sharing_id, threadId: call.thread_id, ownerId: call.owner_id, + hostId: call.host_id, + recState: call.recording, } as Call; }; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 986fc0af3..f34ccc1de 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -2,16 +2,23 @@ // See LICENSE.txt for license information. import {Alert} from 'react-native'; +import {Navigation} from 'react-native-navigation'; -import {hasMicrophonePermission, joinCall, unmuteMyself} from '@calls/actions'; -import {setMicPermissionsGranted} from '@calls/state'; +import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions'; +import {setMicPermissionsGranted, setRecAcknowledged} from '@calls/state'; import {errorAlert} from '@calls/utils'; +import {Screens} from '@constants'; import DatabaseManager from '@database/manager'; import {getCurrentUser} from '@queries/servers/user'; +import {dismissAllModals, dismissAllModalsAndPopToScreen} from '@screens/navigation'; +import NavigationStore from '@store/navigation_store'; import {logError} from '@utils/log'; import type {IntlShape} from 'react-intl'; +// Only allow one recording alert per call. +let recordingAlertLock = false; + export const showLimitRestrictedAlert = (maxParticipants: number, intl: IntlShape) => { const title = intl.formatMessage({ id: 'mobile.calls_participant_limit_title', @@ -107,6 +114,7 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea return; } + recordingAlertLock = false; const hasPermission = await hasMicrophonePermission(); setMicPermissionsGranted(hasPermission); @@ -125,3 +133,51 @@ const doJoinCall = async (serverUrl: string, channelId: string, isDMorGM: boolea setTimeout(() => unmuteMyself(), 1000); } }; + +export const recordingAlert = (intl: IntlShape) => { + if (recordingAlertLock) { + return; + } + recordingAlertLock = true; + + const {formatMessage} = intl; + + 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.', + }); + + Alert.alert( + formatMessage({ + id: 'mobile.calls_participant_rec_title', + defaultMessage: 'Recording is in progress', + }), + 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(), + }, + ], + ); +}; diff --git a/app/products/calls/components/calls_badge.tsx b/app/products/calls/components/calls_badge.tsx new file mode 100644 index 000000000..848461c9e --- /dev/null +++ b/app/products/calls/components/calls_badge.tsx @@ -0,0 +1,88 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StyleSheet, View} from 'react-native'; + +import CompassIcon from '@components/compass_icon'; +import FormattedText from '@components/formatted_text'; +import {typography} from '@utils/typography'; + +const styles = StyleSheet.create({ + container: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 4, + }, + recording: { + paddingLeft: 8, + paddingRight: 8, + backgroundColor: '#D24B4E', + color: 'white', + height: 34, + }, + text: { + color: 'white', + textTransform: 'uppercase', + }, + recordingText: { + marginLeft: 6, + ...typography('Body', 75, 'SemiBold'), + }, + participant: { + marginTop: 6, + paddingLeft: 2, + paddingRight: 6, + paddingTop: 2, + paddingBottom: 2, + backgroundColor: 'rgba(255, 255, 255, 0.16)', + }, + participantText: { + ...typography('Body', 25, 'SemiBold'), + }, +}); + +export enum CallsBadgeType { + Rec, + Host, +} + +interface Props { + type: CallsBadgeType; +} + +const CallsBadge = ({type}: Props) => { + const isRec = type === CallsBadgeType.Rec; + + const text = isRec ? ( + + ) : ( + + ); + + return ( + + { + isRec && + + } + {text} + + ); +}; + +export default CallsBadge; diff --git a/app/products/calls/components/current_call_bar/current_call_bar.tsx b/app/products/calls/components/current_call_bar/current_call_bar.tsx index 3a6bb1f78..32e625154 100644 --- a/app/products/calls/components/current_call_bar/current_call_bar.tsx +++ b/app/products/calls/components/current_call_bar/current_call_bar.tsx @@ -7,6 +7,7 @@ import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native'; import {Options} from 'react-native-navigation'; import {muteMyself, unmuteMyself} from '@calls/actions'; +import {recordingAlert} from '@calls/alerts'; import CallAvatar from '@calls/components/call_avatar'; import PermissionErrorBar from '@calls/components/permission_error_bar'; import UnavailableIconWrapper from '@calls/components/unavailable_icon_wrapper'; @@ -95,7 +96,8 @@ const CurrentCallBar = ({ }: Props) => { const theme = useTheme(); const style = getStyleSheet(theme); - const {formatMessage} = useIntl(); + const intl = useIntl(); + const {formatMessage} = intl; usePermissionsChecker(micPermissionsGranted); const goToCallScreen = useCallback(async () => { @@ -143,6 +145,14 @@ const CurrentCallBar = ({ const micPermissionsError = !micPermissionsGranted && !currentCall?.micPermissionsErrorDismissed; + // 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. + if (currentCall?.recState?.start_at && !currentCall?.recState?.end_at && !currentCall?.recAcknowledged) { + recordingAlert(intl); + } + return ( <> diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index f747585ea..1cd0c6032 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -10,7 +10,9 @@ import { setCallScreenOff, setCallScreenOn, setChannelEnabled, + setHost, setRaisedHand, + setRecordingState, setUserMuted, setUserVoiceOn, userJoinedCall, @@ -60,6 +62,7 @@ export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => { screenOn: '', participants: {}, ownerId: msg.data.owner_id, + hostId: msg.data.host_id, }); }; @@ -98,3 +101,11 @@ export const handleCallUserUnraiseHand = (serverUrl: string, msg: WebSocketMessa export const handleCallUserReacted = (serverUrl: string, msg: WebSocketMessage) => { userReacted(serverUrl, msg.broadcast.channel_id, msg.data); }; + +export const handleCallRecordingState = (serverUrl: string, msg: WebSocketMessage) => { + setRecordingState(serverUrl, msg.broadcast.channel_id, msg.data.recState); +}; + +export const handleCallHostChanged = (serverUrl: string, msg: WebSocketMessage) => { + setHost(serverUrl, msg.broadcast.channel_id, msg.data.hostID); +}; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 4a9f92ef3..fcf4431ec 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -1,17 +1,18 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useCallback, useState} from 'react'; +import React, {useCallback, useEffect, useState} from 'react'; import {useIntl} from 'react-intl'; import { - View, - Text, + DeviceEventEmitter, + Keyboard, Platform, Pressable, SafeAreaView, ScrollView, + Text, useWindowDimensions, - DeviceEventEmitter, Keyboard, + View, } from 'react-native'; import {Navigation} from 'react-native-navigation'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; @@ -19,8 +20,10 @@ import {RTCView} from 'react-native-webrtc'; import {appEntry} from '@actions/remote/entry'; import {leaveCall, muteMyself, setSpeakerphoneOn, unmuteMyself} from '@calls/actions'; +import {recordingAlert} from '@calls/alerts'; import CallAvatar from '@calls/components/call_avatar'; import CallDuration from '@calls/components/call_duration'; +import CallsBadge, {CallsBadgeType} from '@calls/components/calls_badge'; import EmojiList from '@calls/components/emoji_list'; import PermissionErrorBar from '@calls/components/permission_error_bar'; import ReactionBar from '@calls/components/reaction_bar'; @@ -31,7 +34,7 @@ import {sortParticipants} from '@calls/utils'; import CompassIcon from '@components/compass_icon'; import FormattedText from '@components/formatted_text'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import {WebsocketEvents, Screens} from '@constants'; +import {Screens, WebsocketEvents} from '@constants'; import {useTheme} from '@context/theme'; import DatabaseManager from '@database/manager'; import { @@ -78,6 +81,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ }, header: { flexDirection: 'row', + alignItems: 'center', width: '100%', paddingTop: 10, paddingLeft: 14, @@ -114,7 +118,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({ width: '100%', height: '100%', alignContent: 'center', - alignItems: 'center', + alignItems: 'flex-start', }, usersScrollLandscapeScreenOn: { position: 'absolute', @@ -365,6 +369,15 @@ 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 = ( @@ -423,6 +436,7 @@ const CallScreen = ({ ` ${intl.formatMessage({id: 'mobile.calls_you', defaultMessage: '(you)'})}` } + {user.id === currentCall.hostId && } ); })} @@ -450,6 +464,7 @@ const CallScreen = ({ + {recording && } { @@ -212,6 +219,7 @@ describe('useCallsState', () => { screenOn: '', threadId: 'thread-1', ownerId: 'user-1', + hostId: 'user-1', }, }; const expectedChannelsWithCallsState = initialChannelsWithCallsState; @@ -269,6 +277,7 @@ describe('useCallsState', () => { screenOn: '', threadId: 'thread-1', ownerId: 'user-1', + hostId: 'user-1', }, }; const expectedChannelsWithCallsState = initialChannelsWithCallsState; @@ -456,6 +465,7 @@ describe('useCallsState', () => { screenOn: false, threadId: 'thread-1', ownerId: 'user-1', + hostId: 'user-1', }, }; const initialCurrentCallState: CurrentCall = { @@ -888,4 +898,149 @@ describe('useCallsState', () => { assert.deepEqual(result.current[0], initialCallsState); assert.deepEqual(result.current[1], expectedCurrentCallState); }); + + it('setRecordingState', () => { + const initialCallsState = { + ...DefaultCallsState, + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + connected: true, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + }; + const recState: RecordingState = { + init_at: 123, + start_at: 231, + end_at: 345, + }; + const expectedCallsState: CallsState = { + ...initialCallsState, + calls: { + ...initialCallsState.calls, + 'channel-1': { + ...call1, + recState, + }, + }, + }; + const expectedCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + connected: true, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + recState, + }; + + // 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(() => setRecordingState('server1', 'channel-1', recState)); + assert.deepEqual((result.current[0] as CallsState), expectedCallsState); + assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); + act(() => setRecordingState('server1', 'channel-2', recState)); + assert.deepEqual((result.current[0] as CallsState).calls['channel-2'], {...call2, recState}); + 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, + calls: {'channel-1': call1, 'channel-2': call2}, + }; + const initialCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + connected: true, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + }; + const expectedCallsState: CallsState = { + ...initialCallsState, + calls: { + ...initialCallsState.calls, + 'channel-1': { + ...call1, + hostId: 'user-52', + }, + }, + }; + const expectedCurrentCallState: CurrentCall = { + ...DefaultCurrentCall, + connected: true, + serverUrl: 'server1', + myUserId: 'myUserId', + ...call1, + hostId: 'user-52', + }; + + // 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(() => setHost('server1', 'channel-1', 'user-52')); + assert.deepEqual((result.current[0] as CallsState), expectedCallsState); + assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); + act(() => setHost('server1', 'channel-2', 'user-1923')); + assert.deepEqual((result.current[0] as CallsState).calls['channel-2'], {...call2, hostId: 'user-1923'}); + assert.deepEqual((result.current[1] as CurrentCall | null), expectedCurrentCallState); + }); }); diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index ee5be353d..ad0c8942e 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -20,7 +20,9 @@ import { ChannelsWithCalls, CurrentCall, DefaultCall, - DefaultCurrentCall, ReactionStreamEmoji, + DefaultCurrentCall, + ReactionStreamEmoji, + RecordingState, } from '@calls/types/calls'; import {REACTION_LIMIT, REACTION_TIMEOUT} from '@constants/calls'; @@ -493,3 +495,62 @@ const userReactionTimeout = (serverUrl: string, channelId: string, reaction: Cal }; setCurrentCall(nextCurrentCall); }; + +export const setRecordingState = (serverUrl: string, channelId: string, recState: RecordingState) => { + const callsState = getCallsState(serverUrl); + if (!callsState.calls[channelId]) { + return; + } + + const nextCall = {...callsState.calls[channelId], recState}; + const nextCalls = {...callsState.calls, [channelId]: nextCall}; + setCallsState(serverUrl, {...callsState, calls: nextCalls}); + + // Was it the current call? If so, update that too. + const currentCall = getCurrentCall(); + if (!currentCall || currentCall.channelId !== channelId) { + return; + } + + const nextCurrentCall = { + ...currentCall, + 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]) { + return; + } + + const nextCall = {...callsState.calls[channelId], hostId}; + const nextCalls = {...callsState.calls, [channelId]: nextCall}; + setCallsState(serverUrl, {...callsState, calls: nextCalls}); + + // Was it the current call? If so, update that too. + const currentCall = getCurrentCall(); + if (!currentCall || currentCall.channelId !== channelId) { + return; + } + + const nextCurrentCall = { + ...currentCall, + hostId, + }; + setCurrentCall(nextCurrentCall); +}; diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index bfdf8fc66..08bb6b322 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -33,6 +33,8 @@ export type Call = { screenOn: string; threadId: string; ownerId: string; + recState?: RecordingState; + hostId: string; } export const DefaultCall: Call = { @@ -42,6 +44,7 @@ export const DefaultCall: Call = { screenOn: '', threadId: '', ownerId: '', + hostId: '', }; export type CurrentCall = Call & { @@ -53,23 +56,20 @@ export type CurrentCall = Call & { voiceOn: Dictionary; micPermissionsErrorDismissed: boolean; reactionStream: ReactionStreamEmoji[]; + recAcknowledged: boolean; } export const DefaultCurrentCall: CurrentCall = { + ...DefaultCall, connected: false, serverUrl: '', myUserId: '', - participants: {}, - channelId: '', - startTime: 0, - screenOn: '', - threadId: '', - ownerId: '', screenShareURL: '', speakerphoneOn: false, voiceOn: {}, micPermissionsErrorDismissed: false, reactionStream: [], + recAcknowledged: false, }; export type CallParticipant = { @@ -101,6 +101,8 @@ export type ServerCallState = { thread_id: string; screen_sharing_id: string; owner_id: string; + host_id: string; + recording: RecordingState; } export type CallsConnection = { @@ -166,3 +168,9 @@ export type ReactionStreamEmoji = { latestTimestamp: number; count: number; }; + +export type RecordingState = { + init_at: number; + start_at: number; + end_at: number; +} diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index c81cc17ed..c5841f1f2 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -374,6 +374,7 @@ "mobile.calls_ended_at": "Ended at", "mobile.calls_error_message": "Error: {error}", "mobile.calls_error_title": "Error", + "mobile.calls_host": "host", "mobile.calls_join_call": "Join call", "mobile.calls_lasted": "Lasted {duration}", "mobile.calls_leave": "Leave", @@ -391,9 +392,13 @@ "mobile.calls_not_available_option": "(Not available)", "mobile.calls_not_available_title": "Calls is not enabled", "mobile.calls_ok": "OK", + "mobile.calls_okay": "Okay", "mobile.calls_participant_limit_title": "Participant limit reached", + "mobile.calls_participant_rec": "The host has started recording this meeting. By staying in the meeting you give consent to being recorded.", + "mobile.calls_participant_rec_title": "Recording is in progress", "mobile.calls_raise_hand": "Raise hand", "mobile.calls_react": "React", + "mobile.calls_rec": "rec", "mobile.calls_see_logs": "See server logs", "mobile.calls_speaker": "Speaker", "mobile.calls_start_call": "Start Call",