[MM-46339] Handle user leaving channel with active call (#7754) (#7759)

* Handle user leaving channel with active call

* Remove debug log

(cherry picked from commit 84858e4450)

Co-authored-by: Claudio Costa <cstcld91@gmail.com>
This commit is contained in:
Mattermost Build 2024-01-15 17:16:50 +02:00 committed by GitHub
parent 810f4b3d24
commit 35c064ff9a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 226 additions and 17 deletions

View file

@ -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) {

View file

@ -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();
});
});

View file

@ -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;
}
};

View file

@ -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);
}
};

View file

@ -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);
}
};

View file

@ -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');

View file

@ -124,7 +124,7 @@ export type CallSession = {
export type ChannelsWithCalls = Dictionary<boolean>;
export type CallsConnection = {
disconnect: () => void;
disconnect: (err?: Error) => void;
mute: () => void;
unmute: () => void;
waitForPeerConnection: () => Promise<void>;

View file

@ -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",