diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index bf9437d74..f87e9095f 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -9,6 +9,7 @@ import InCallManager from 'react-native-incall-manager'; import * as CallsActions from '@calls/actions'; import {getConnectionForTesting} from '@calls/actions/calls'; import * as Permissions from '@calls/actions/permissions'; +import {needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts'; import * as State from '@calls/state'; import { myselfLeftCall, @@ -63,6 +64,8 @@ const mockClient = { ] )), enableChannelCalls: jest.fn(), + startCallRecording: jest.fn(), + stopCallRecording: jest.fn(), }; jest.mock('@calls/connection/connection', () => ({ @@ -74,6 +77,8 @@ jest.mock('@calls/connection/connection', () => ({ })), })); +jest.mock('@calls/alerts'); + const addFakeCall = (serverUrl: string, channelId: string) => { const call = { participants: { @@ -339,4 +344,23 @@ describe('Actions.Calls', () => { expect(mockClient.enableChannelCalls).toBeCalledWith('channel-1', false); assert.equal(result.current.enabled['channel-1'], false); }); + + it('startCallRecording', async () => { + await act(async () => { + await CallsActions.startCallRecording('server1', 'channel-id'); + }); + + expect(mockClient.startCallRecording).toBeCalledWith('channel-id'); + expect(needsRecordingErrorAlert).toBeCalled(); + }); + + it('stopCallRecording', async () => { + await act(async () => { + await CallsActions.stopCallRecording('server1', 'channel-id'); + }); + + expect(mockClient.stopCallRecording).toBeCalledWith('channel-id'); + expect(needsRecordingErrorAlert).toBeCalled(); + expect(needsRecordingWillBePostedAlert).toBeCalled(); + }); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 61416ebd5..96ca93af5 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -7,7 +7,7 @@ import {Navigation} from 'react-native-navigation'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {fetchUsersByIds} from '@actions/remote/user'; -import {leaveAndJoinWithAlert, needsRecordingWillBePostedAlert} from '@calls/alerts'; +import {leaveAndJoinWithAlert, needsRecordingWillBePostedAlert, needsRecordingErrorAlert} from '@calls/alerts'; import { getCallsConfig, getCallsState, @@ -411,6 +411,8 @@ export const endCall = async (serverUrl: string, channelId: string) => { }; export const startCallRecording = async (serverUrl: string, callId: string) => { + needsRecordingErrorAlert(); + const client = NetworkManager.getClient(serverUrl); let data: ApiResp | RecordingState; @@ -427,6 +429,7 @@ export const startCallRecording = async (serverUrl: string, callId: string) => { export const stopCallRecording = async (serverUrl: string, callId: string) => { needsRecordingWillBePostedAlert(); + needsRecordingErrorAlert(); const client = NetworkManager.getClient(serverUrl); @@ -481,6 +484,73 @@ export const handleCallsSlashCommand = async (value: string, serverUrl: string, defaultMessage: 'You\'re not connected to a call in the current channel.', }), }; + case 'recording': { + if (tokens.length < 3) { + return {handled: false}; + } + + const action = tokens[2]; + const currentCall = getCurrentCall(); + const recording = currentCall?.recState; + const isHost = currentCall?.hostId === currentUserId; + + if (currentCall?.channelId !== channelId) { + return { + error: intl.formatMessage({ + id: 'mobile.calls_not_connected', + defaultMessage: 'You\'re not connected to a call in the current channel.', + }), + }; + } + + if (action === 'start') { + if (recording && recording.start_at > recording.end_at) { + return { + error: intl.formatMessage({ + id: 'mobile.calls_recording_start_in_progress', + defaultMessage: 'A recording is already in progress.', + }), + }; + } + + if (!isHost) { + return { + error: intl.formatMessage({ + id: 'mobile.calls_recording_start_no_permissions', + defaultMessage: 'You don\'t have permissions to start a recording. Please ask the call host to start a recording.', + }), + }; + } + + await startCallRecording(currentCall.serverUrl, currentCall.channelId); + + return {handled: true}; + } + + if (action === 'stop') { + if (!recording || recording.end_at > recording.start_at) { + return { + error: intl.formatMessage({ + id: 'mobile.calls_recording_stop_none_in_progress', + defaultMessage: 'No recording is in progress.', + }), + }; + } + + if (!isHost) { + return { + error: intl.formatMessage({ + id: 'mobile.calls_recording_stop_no_permissions', + defaultMessage: 'You don\'t have permissions to stop the recording. Please ask the call host to stop the recording.', + }), + }; + } + + await stopCallRecording(currentCall.serverUrl, currentCall.channelId); + + return {handled: true}; + } + } } return {handled: false}; diff --git a/app/products/calls/actions/index.ts b/app/products/calls/actions/index.ts index 612d9bfa2..fec8ba771 100644 --- a/app/products/calls/actions/index.ts +++ b/app/products/calls/actions/index.ts @@ -13,6 +13,8 @@ export { unraiseHand, setSpeakerphoneOn, handleCallsSlashCommand, + startCallRecording, + stopCallRecording, } from './calls'; export {hasMicrophonePermission} from './permissions'; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 1ff6fafc8..feab1d604 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -23,12 +23,18 @@ import {isSystemAdmin} from '@utils/user'; import type {LimitRestrictedInfo} from '@calls/observers'; import type {IntlShape} from 'react-intl'; -// Only allow one recording alert per call. -let recordingAlertLock = false; +// Only unlock when: +// - Joining a new call. +// - A new recording has started. +// - Host has changed to current user. +let recordingAlertLock = true; // Only unlock if/when the user starts a recording. let recordingWillBePostedLock = true; +// Only unlock when starting/stopping a recording. +let recordingErrorLock = true; + export const showLimitRestrictedAlert = (info: LimitRestrictedInfo, intl: IntlShape) => { const title = intl.formatMessage({ id: 'mobile.calls_participant_limit_title_GA', @@ -224,6 +230,10 @@ const contactAdminAlert = ({formatMessage}: IntlShape) => { ); }; +export const needsRecordingAlert = () => { + recordingAlertLock = false; +}; + export const recordingAlert = (isHost: boolean, intl: IntlShape) => { if (recordingAlertLock) { return; @@ -311,3 +321,33 @@ export const recordingWillBePostedAlert = (intl: IntlShape) => { }], ); }; + +export const needsRecordingErrorAlert = () => { + recordingErrorLock = false; +}; + +export const recordingErrorAlert = (intl: IntlShape) => { + if (recordingErrorLock) { + return; + } + recordingErrorLock = true; + + const {formatMessage} = intl; + + Alert.alert( + formatMessage({ + id: 'mobile.calls_host_rec_error_title', + defaultMessage: 'Something went wrong with the recording', + }), + formatMessage({ + id: 'mobile.calls_host_rec_error', + defaultMessage: 'Please try to record again. You can also contact your system admin for troubleshooting help.', + }), + [{ + text: formatMessage({ + id: 'mobile.calls_dismiss', + defaultMessage: 'Dismiss', + }), + }], + ); +}; 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 0e9d9a4a0..09d7598e3 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 @@ -6,7 +6,7 @@ import {useIntl} from 'react-intl'; import {View, Text, TouchableOpacity, Pressable, Platform} from 'react-native'; import {muteMyself, unmuteMyself} from '@calls/actions'; -import {recordingAlert, recordingWillBePostedAlert} from '@calls/alerts'; +import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} 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'; @@ -158,6 +158,11 @@ const CurrentCallBar = ({ recordingWillBePostedAlert(intl); } + // The host should receive an alert in case of unexpected error. + if (isHost && currentCall?.recState?.err) { + recordingErrorAlert(intl); + } + return ( <> diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 6fc112849..679bd3707 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -21,7 +21,7 @@ import {RTCView} from 'react-native-webrtc'; import {appEntry} from '@actions/remote/entry'; import {leaveCall, muteMyself, setSpeakerphoneOn, unmuteMyself} from '@calls/actions'; import {startCallRecording, stopCallRecording} from '@calls/actions/calls'; -import {recordingAlert, recordingWillBePostedAlert} from '@calls/alerts'; +import {recordingAlert, recordingWillBePostedAlert, recordingErrorAlert} 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'; @@ -375,6 +375,11 @@ const CallScreen = ({ recordingWillBePostedAlert(intl); } + // The host should receive an alert in case of unexpected error. + if (isHost && currentCall?.recState?.err) { + recordingErrorAlert(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); diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index 073ec7434..c401be6b7 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import {act, renderHook} from '@testing-library/react-hooks'; +import {needsRecordingAlert} from '@calls/alerts'; import { newCurrentCall, setCallsState, @@ -53,6 +54,8 @@ import { RecordingState, } from '../types/calls'; +jest.mock('@calls/alerts'); + const call1: Call = { participants: { 'user-1': {id: 'user-1', muted: false, raisedHand: 0}, @@ -955,6 +958,8 @@ describe('useCallsState', () => { 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); + act(() => setRecordingState('server1', 'channel-1', {...recState, start_at: recState.start_at + 1})); + expect(needsRecordingAlert).toBeCalled(); }); it('setHost', () => { @@ -1006,5 +1011,7 @@ describe('useCallsState', () => { 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); + act(() => setHost('server1', 'channel-1', 'myUserId')); + expect(needsRecordingAlert).toBeCalled(); }); }); diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 9a5be5773..b172511aa 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -1,6 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {needsRecordingAlert} from '@calls/alerts'; import { getCallsConfig, getCallsState, @@ -513,6 +514,11 @@ export const setRecordingState = (serverUrl: string, channelId: string, recState return; } + // If a new call has started, we reset the alert state so it can be showed again. + if (currentCall.recState && recState.start_at > currentCall.recState.start_at) { + needsRecordingAlert(); + } + const nextCurrentCall = { ...currentCall, recState, @@ -536,6 +542,11 @@ export const setHost = (serverUrl: string, channelId: string, hostId: string) => return; } + // If we are the new host we show the alert again. + if (currentCall.myUserId === hostId) { + needsRecordingAlert(); + } + const nextCurrentCall = { ...currentCall, hostId, diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index e73e5c03c..53bdbec5c 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -173,4 +173,5 @@ export type RecordingState = { init_at: number; start_at: number; end_at: number; + err?: string; } diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 44e484b93..8fea42265 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -414,6 +414,8 @@ "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_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", "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", @@ -445,6 +447,10 @@ "mobile.calls_react": "React", "mobile.calls_rec": "rec", "mobile.calls_record": "Record", + "mobile.calls_recording_start_in_progress": "A recording is already in progress.", + "mobile.calls_recording_start_no_permissions": "You don't have permissions to start a recording. Please ask the call host to start a recording.", + "mobile.calls_recording_stop_no_permissions": "You don't have permissions to stop the recording. Please ask the call host to stop the recording.", + "mobile.calls_recording_stop_none_in_progress": "No recording is in progress.", "mobile.calls_request_message": "Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance", "mobile.calls_request_title": "Calls is not currently enabled", "mobile.calls_see_logs": "See server logs",