diff --git a/app/actions/websocket/channel.ts b/app/actions/websocket/channel.ts index bb5d79902..91a32e548 100644 --- a/app/actions/websocket/channel.ts +++ b/app/actions/websocket/channel.ts @@ -11,7 +11,9 @@ import {fetchMissingDirectChannelsInfo, fetchMyChannel, fetchChannelStats, fetch import {fetchPostsForChannel} from '@actions/remote/post'; import {fetchRolesIfNeeded} from '@actions/remote/role'; import {fetchUsersByIds, updateUsersNoLongerVisible} from '@actions/remote/user'; -import {loadCallForChannel} from '@calls/actions/calls'; +import {loadCallForChannel, leaveCall} from '@calls/actions/calls'; +import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors'; +import {getCurrentCall} from '@calls/state'; import {Events, General} from '@constants'; import DatabaseManager from '@database/manager'; import {deleteChannelMembership, getChannelById, prepareMyChannelsForTeam, getCurrentChannel} from '@queries/servers/channel'; @@ -360,6 +362,9 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: const channelId = msg.data.channel_id || msg.broadcast.channel_id; if (EphemeralStore.isLeavingChannel(channelId)) { + if (getCurrentCall()?.channelId === channelId) { + leaveCall(userLeftChannelErr); + } return; } @@ -382,7 +387,12 @@ export async function handleUserRemovedFromChannelEvent(serverUrl: string, msg: if (currentChannelId && currentChannelId === channelId) { await handleKickFromChannel(serverUrl, currentChannelId); } + await removeCurrentUserFromChannel(serverUrl, channelId); + + if (getCurrentCall()?.channelId === channelId) { + leaveCall(userRemovedFromChannelErr); + } } else { const {models: deleteMemberModels} = await deleteChannelMembership(operator, userId, channelId, true); if (deleteMemberModels) { diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 1b2e35dba..986d36bc7 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -4,12 +4,14 @@ import assert from 'assert'; import {act, renderHook} from '@testing-library/react-hooks'; +import {createIntl} from 'react-intl'; 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 {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors'; import * as State from '@calls/state'; import { myselfLeftCall, @@ -32,6 +34,7 @@ import { DefaultCallsConfig, DefaultCallsState, } from '@calls/types/calls'; +import {errorAlert} from '@calls/utils'; import DatabaseManager from '@database/manager'; import NetworkManager from '@managers/network_manager'; @@ -71,8 +74,8 @@ const mockClient = { }; jest.mock('@calls/connection/connection', () => ({ - newConnection: jest.fn(() => Promise.resolve({ - disconnect: jest.fn(), + newConnection: jest.fn((serverURL, channelId, onClose) => Promise.resolve({ + disconnect: jest.fn((err?: Error) => onClose(err)), mute: jest.fn(), unmute: jest.fn(), waitForPeerConnection: jest.fn(() => Promise.resolve()), @@ -89,7 +92,17 @@ jest.mock('@queries/servers/thread', () => ({ })), })); -jest.mock('@calls/alerts'); +jest.mock('@calls/alerts', () => { + const alerts = jest.requireActual('../alerts'); + return { + needsRecordingErrorAlert: jest.fn(), + needsRecordingWillBePostedAlert: jest.fn(), + showErrorAlertOnClose: alerts.showErrorAlertOnClose, + }; +}); + +jest.mock('@calls/utils'); + jest.mock('react-native-navigation', () => ({ Navigation: { pop: jest.fn(() => Promise.resolve({ @@ -174,7 +187,10 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true); + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({ + locale: 'en', + messages: {}, + })); // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); @@ -201,7 +217,10 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true); + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({ + locale: 'en', + messages: {}, + })); // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); @@ -235,7 +254,10 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true); + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, createIntl({ + locale: 'en', + messages: {}, + })); // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); @@ -265,7 +287,10 @@ describe('Actions.Calls', () => { let response: { data?: string }; await act(async () => { - response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true); + response = await CallsActions.joinCall('server1', 'channel-id', 'mysUserId', true, createIntl({ + locale: 'en', + messages: {}, + })); // manually call newCurrentConnection because newConnection is mocked newCurrentCall('server1', 'channel-id', 'myUserId'); @@ -396,4 +421,125 @@ describe('Actions.Calls', () => { expect(mockClient.dismissCall).toBeCalledWith('channel-id'); }); + + it('userLeftChannelErr', async () => { + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + addFakeCall('server1', 'channel-id'); + + let response: { data?: string }; + + const intl = createIntl({ + locale: 'en', + messages: {}, + }); + intl.formatMessage = jest.fn(); + + await act(async () => { + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl); + + // manually call newCurrentConnection because newConnection is mocked + newCurrentCall('server1', 'channel-id', 'myUserId'); + }); + + assert.equal(response!.data, 'channel-id'); + assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id'); + expect(newConnection).toBeCalled(); + expect(newConnection.mock.calls[0][1]).toBe('channel-id'); + expect(updateThreadFollowing).toBeCalled(); + + await act(async () => { + CallsActions.leaveCall(userLeftChannelErr); + }); + + expect(intl.formatMessage).toBeCalledWith({ + id: 'mobile.calls_user_left_channel_error_title', + defaultMessage: 'You left the channel', + }); + + expect(intl.formatMessage).toBeCalledWith({ + id: 'mobile.calls_user_left_channel_error_message', + defaultMessage: 'You have left the channel, and have been disconnected from the call.', + }); + }); + + it('userRemovedFromChannelErr', async () => { + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + addFakeCall('server1', 'channel-id'); + + let response: { data?: string }; + + const intl = createIntl({ + locale: 'en', + messages: {}, + }); + intl.formatMessage = jest.fn(); + + await act(async () => { + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl); + + // manually call newCurrentConnection because newConnection is mocked + newCurrentCall('server1', 'channel-id', 'myUserId'); + }); + + assert.equal(response!.data, 'channel-id'); + assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id'); + expect(newConnection).toBeCalled(); + expect(newConnection.mock.calls[0][1]).toBe('channel-id'); + expect(updateThreadFollowing).toBeCalled(); + + await act(async () => { + CallsActions.leaveCall(userRemovedFromChannelErr); + }); + + expect(intl.formatMessage).toBeCalledWith({ + id: 'mobile.calls_user_removed_from_channel_error_title', + defaultMessage: 'You were removed from channel', + }); + + expect(intl.formatMessage).toBeCalledWith({ + id: 'mobile.calls_user_removed_from_channel_error_message', + defaultMessage: 'You have been removed from the channel, and have been disconnected from the call.', + }); + }); + + it('generic error on close', async () => { + // setup + const {result} = renderHook(() => { + return [useCallsState('server1'), useCurrentCall()]; + }); + addFakeCall('server1', 'channel-id'); + + let response: { data?: string }; + + const intl = createIntl({ + locale: 'en', + messages: {}, + }); + intl.formatMessage = jest.fn(); + + await act(async () => { + response = await CallsActions.joinCall('server1', 'channel-id', 'myUserId', true, intl); + + // manually call newCurrentConnection because newConnection is mocked + newCurrentCall('server1', 'channel-id', 'myUserId'); + }); + + assert.equal(response!.data, 'channel-id'); + assert.equal((result.current[1] as CurrentCall).channelId, 'channel-id'); + expect(newConnection).toBeCalled(); + expect(newConnection.mock.calls[0][1]).toBe('channel-id'); + expect(updateThreadFollowing).toBeCalled(); + + await act(async () => { + CallsActions.leaveCall(new Error('generic error')); + }); + + expect(errorAlert).toBeCalled(); + }); }); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index e9c2a3e23..c2b249bc4 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -7,7 +7,12 @@ import InCallManager from 'react-native-incall-manager'; import {forceLogoutIfNecessary} from '@actions/remote/session'; import {updateThreadFollowing} from '@actions/remote/thread'; import {fetchUsersByIds} from '@actions/remote/user'; -import {leaveAndJoinWithAlert, needsRecordingErrorAlert, needsRecordingWillBePostedAlert} from '@calls/alerts'; +import { + leaveAndJoinWithAlert, + needsRecordingErrorAlert, + needsRecordingWillBePostedAlert, + showErrorAlertOnClose, +} from '@calls/alerts'; import { getCallsConfig, getCallsState, @@ -230,6 +235,7 @@ export const joinCall = async ( channelId: string, userId: string, hasMicPermission: boolean, + intl: IntlShape, title?: string, rootId?: string, ): Promise<{ error?: unknown; data?: string }> => { @@ -248,8 +254,12 @@ export const joinCall = async ( newCurrentCall(serverUrl, channelId, userId); try { - connection = await newConnection(serverUrl, channelId, () => { + connection = await newConnection(serverUrl, channelId, (err?: Error) => { myselfLeftCall(); + if (err) { + logDebug('calls: error on close', getFullErrorMessage(err)); + showErrorAlertOnClose(err, intl); + } }, setScreenShareURL, hasMicPermission, title, rootId); } catch (error) { await forceLogoutIfNecessary(serverUrl, error); @@ -285,9 +295,9 @@ export const joinCall = async ( } }; -export const leaveCall = () => { +export const leaveCall = (err?: Error) => { if (connection) { - connection.disconnect(); + connection.disconnect(err); connection = null; } }; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 7c5ae9d08..9df53b23a 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -6,6 +6,7 @@ import {Alert} from 'react-native'; import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions'; import {dismissIncomingCall} from '@calls/actions/calls'; import {hasBluetoothPermission} from '@calls/actions/permissions'; +import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors'; import { getCallsConfig, getCallsState, @@ -19,6 +20,7 @@ import DatabaseManager from '@database/manager'; import {getChannelById} from '@queries/servers/channel'; import {getCurrentUser} from '@queries/servers/user'; import {isDMorGM} from '@utils/channel'; +import {getFullErrorMessage} from '@utils/errors'; import {logError} from '@utils/log'; import {isSystemAdmin} from '@utils/user'; @@ -208,7 +210,7 @@ const doJoinCall = async ( removeIncomingCall(serverUrl, callId, channelId); } - const res = await joinCall(serverUrl, channelId, user.id, hasPermission, title, rootId); + const res = await joinCall(serverUrl, channelId, user.id, hasPermission, intl, title, rootId); if (res.error) { const seeLogs = formatMessage({id: 'mobile.calls_see_logs', defaultMessage: 'See server logs'}); errorAlert(res.error?.toString() || seeLogs, intl); @@ -384,3 +386,35 @@ export const recordingErrorAlert = (intl: IntlShape) => { }], ); }; + +export const showErrorAlertOnClose = (err: Error, intl: IntlShape) => { + switch (err) { + case userLeftChannelErr: + Alert.alert( + intl.formatMessage({ + id: 'mobile.calls_user_left_channel_error_title', + defaultMessage: 'You left the channel', + }), + intl.formatMessage({ + id: 'mobile.calls_user_left_channel_error_message', + defaultMessage: 'You have left the channel, and have been disconnected from the call.', + }), + ); + break; + case userRemovedFromChannelErr: + Alert.alert( + intl.formatMessage({ + id: 'mobile.calls_user_removed_from_channel_error_title', + defaultMessage: 'You were removed from channel', + }), + intl.formatMessage({ + id: 'mobile.calls_user_removed_from_channel_error_message', + defaultMessage: 'You have been removed from the channel, and have been disconnected from the call.', + }), + ); + break; + default: + // Fallback with generic error + errorAlert(getFullErrorMessage(err, intl), intl); + } +}; diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index bf96d10ed..2586e29cc 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/connection.ts @@ -39,7 +39,7 @@ if (Platform.OS === 'android') { export async function newConnection( serverUrl: string, channelID: string, - closeCb: () => void, + closeCb: (err?: Error) => void, setScreenShareURL: (url: string) => void, hasMicPermission: boolean, title?: string, @@ -93,7 +93,7 @@ export async function newConnection( initializeVoiceTrack(); } - const disconnect = () => { + const disconnect = (err?: Error) => { if (isClosed) { return; } @@ -126,7 +126,7 @@ export async function newConnection( } if (closeCb) { - closeCb(); + closeCb(err); } }; diff --git a/app/products/calls/errors.ts b/app/products/calls/errors.ts new file mode 100644 index 000000000..2fea518a8 --- /dev/null +++ b/app/products/calls/errors.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export const userRemovedFromChannelErr = new Error('user was removed from channel'); +export const userLeftChannelErr = new Error('user has left channel'); diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 3d0caa06e..562cea70e 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -124,7 +124,7 @@ export type CallSession = { export type ChannelsWithCalls = Dictionary; export type CallsConnection = { - disconnect: () => void; + disconnect: (err?: Error) => void; mute: () => void; unmute: () => void; waitForPeerConnection: () => Promise; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index f6d0b6712..062ad5afa 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -517,6 +517,10 @@ "mobile.calls_tablet": "Tablet", "mobile.calls_thread": "Thread", "mobile.calls_unmute": "Unmute", + "mobile.calls_user_left_channel_error_message": "You have left the channel, and have been disconnected from the call.", + "mobile.calls_user_left_channel_error_title": "You left the channel", + "mobile.calls_user_removed_from_channel_error_message": "You have been removed from the channel, and have been disconnected from the call.", + "mobile.calls_user_removed_from_channel_error_title": "You were removed from channel", "mobile.calls_viewing_screen": "You are viewing {name}'s screen", "mobile.calls_you": "(you)", "mobile.calls_you_2": "You",