MM-45746 - Handle call_end ws event and '/call end' slash command (#6542)

This commit is contained in:
Christopher Poile
2022-08-05 07:39:10 -04:00
committed by GitHub
parent 4dfb9fb60c
commit f61ea842af
15 changed files with 242 additions and 25 deletions

View File

@@ -11,11 +11,18 @@ import {fetchStatusByIds} from '@actions/remote/user';
import {loadConfigAndCalls} from '@calls/actions/calls';
import {
handleCallChannelDisabled,
handleCallChannelEnabled, handleCallScreenOff, handleCallScreenOn, handleCallStarted,
handleCallChannelEnabled, handleCallEnded,
handleCallScreenOff,
handleCallScreenOn,
handleCallStarted,
handleCallUserConnected,
handleCallUserDisconnected,
handleCallUserMuted, handleCallUserRaiseHand,
handleCallUserUnmuted, handleCallUserUnraiseHand, handleCallUserVoiceOff, handleCallUserVoiceOn,
handleCallUserMuted,
handleCallUserRaiseHand,
handleCallUserUnmuted,
handleCallUserUnraiseHand,
handleCallUserVoiceOff,
handleCallUserVoiceOn,
} from '@calls/connection/websocket_event_handlers';
import {isSupportedServerCalls} from '@calls/utils';
import {Events, Screens, WebsocketEvents} from '@constants';
@@ -402,6 +409,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
case WebsocketEvents.CALLS_USER_UNRAISE_HAND:
handleCallUserUnraiseHand(serverUrl, msg);
break;
case WebsocketEvents.CALLS_CALL_END:
handleCallEnded(serverUrl, msg);
break;
case WebsocketEvents.GROUP_RECEIVED:
handleGroupReceivedEvent(serverUrl, msg);

View File

@@ -3,13 +3,15 @@
import React, {useCallback, useEffect, useState} from 'react';
import {useIntl} from 'react-intl';
import {DeviceEventEmitter} from 'react-native';
import {Alert, DeviceEventEmitter} from 'react-native';
import {getChannelTimezones} from '@actions/remote/channel';
import {executeCommand, handleGotoLocation} from '@actions/remote/command';
import {createPost} from '@actions/remote/post';
import {handleReactionToLatestPost} from '@actions/remote/reactions';
import {setStatus} from '@actions/remote/user';
import {canEndCall, endCall, getEndCallMessage} from '@calls/actions/calls';
import ClientError from '@client/rest/error';
import {Events, Screens} from '@constants';
import {NOTIFY_ALL_MEMBERS} from '@constants/post_draft';
import {useServerUrl} from '@context/server';
@@ -128,7 +130,55 @@ export default function SendHandler({
DraftUtils.alertChannelWideMention(intl, notifyAllMessage, doSubmitMessage, cancel);
}, [intl, isTimezoneEnabled, channelTimezoneCount, doSubmitMessage]);
const handleEndCall = useCallback(async () => {
const hasPermissions = await canEndCall(serverUrl, channelId);
if (!hasPermissions) {
Alert.alert(
intl.formatMessage({
id: 'mobile.calls_end_permission_title',
defaultMessage: 'Error',
}),
intl.formatMessage({
id: 'mobile.calls_end_permission_msg',
defaultMessage: 'You do not have permission to end the call. Please ask the call owner to end the call.',
}));
return;
}
const message = await getEndCallMessage(serverUrl, channelId, currentUserId, intl);
const title = intl.formatMessage({id: 'mobile.calls_end_call_title', defaultMessage: 'End call'});
Alert.alert(
title,
message,
[
{
text: intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'}),
},
{
text: title,
onPress: async () => {
try {
await endCall(serverUrl, channelId);
} catch (e) {
const err = (e as ClientError).message || 'unable to complete command, see server logs';
Alert.alert('Error', `Error: ${err}`);
}
},
style: 'cancel',
},
],
);
}, [serverUrl, channelId, currentUserId, intl]);
const sendCommand = useCallback(async () => {
if (value.trim() === '/call end') {
await handleEndCall();
// NOTE: fallthrough because the server may want to handle the command as well
}
const status = DraftUtils.getStatusFromSlashCommand(value);
if (userIsOutOfOffice && status) {
const updateStatus = (newStatus: string) => {
@@ -163,7 +213,7 @@ export default function SendHandler({
if (data?.goto_location && !value.startsWith('/leave')) {
handleGotoLocation(serverUrl, intl, data.goto_location);
}
}, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId]);
}, [userIsOutOfOffice, currentUserId, intl, value, serverUrl, channelId, rootId, handleEndCall]);
const sendMessage = useCallback(() => {
const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions;

View File

@@ -85,6 +85,7 @@ const addFakeCall = (serverUrl: string, channelId: string) => {
startTime: (new Date()).getTime(),
screenOn: '',
threadId: 'abcd1234567',
ownerId: 'xohi8cki9787fgiryne716u84o',
} as Call;
act(() => {
State.callStarted(serverUrl, call);

View File

@@ -6,7 +6,7 @@ import InCallManager from 'react-native-incall-manager';
import {forceLogoutIfNecessary} from '@actions/remote/session';
import {fetchUsersByIds} from '@actions/remote/user';
import {
getCallsConfig,
getCallsConfig, getCallsState,
myselfJoinedCall,
myselfLeftCall,
setCalls,
@@ -16,19 +16,29 @@ import {
setScreenShareURL,
setSpeakerPhone,
} from '@calls/state';
import {
import {General, Preferences} from '@constants';
import Calls from '@constants/calls';
import DatabaseManager from '@database/manager';
import {getTeammateNameDisplaySetting} from '@helpers/api/preference';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {queryPreferencesByCategoryAndName} from '@queries/servers/preference';
import {getCommonSystemValues} from '@queries/servers/system';
import {getCurrentUser, getUserById} from '@queries/servers/user';
import {displayUsername, getUserIdFromChannelName, isSystemAdmin} from '@utils/user';
import {newConnection} from '../connection/connection';
import type {
ApiResp,
Call,
CallParticipant,
CallsConnection,
ServerChannelState,
} from '@calls/types/calls';
import Calls from '@constants/calls';
import NetworkManager from '@managers/network_manager';
import {newConnection} from '../connection/connection';
import type {Client} from '@client/rest';
import type ClientError from '@client/rest/error';
import type {IntlShape} from 'react-intl';
let connection: CallsConnection | null = null;
export const getConnectionForTesting = () => connection;
@@ -101,6 +111,7 @@ export const loadCalls = async (serverUrl: string, userId: string) => {
startTime: call.start_at,
screenOn: call.screen_sharing_id,
threadId: call.thread_id,
ownerId: call.owner_id,
};
}
enabledChannels[channel.channel_id] = channel.enabled;
@@ -236,3 +247,79 @@ export const setSpeakerphoneOn = (speakerphoneOn: boolean) => {
InCallManager.setSpeakerphoneOn(speakerphoneOn);
setSpeakerPhone(speakerphoneOn);
};
export const canEndCall = async (serverUrl: string, channelId: string) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return false;
}
const currentUser = await getCurrentUser(database);
if (!currentUser) {
return false;
}
const call = getCallsState(serverUrl).calls[channelId];
if (!call) {
return false;
}
return isSystemAdmin(currentUser.roles) || currentUser.id === call.ownerId;
};
export const getEndCallMessage = async (serverUrl: string, channelId: string, currentUserId: string, intl: IntlShape) => {
let msg = intl.formatMessage({
id: 'mobile.calls_end_msg_channel_default',
defaultMessage: 'Are you sure you want to end the call?',
});
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return msg;
}
const channel = await getChannelById(database, channelId);
if (!channel) {
return msg;
}
const call = getCallsState(serverUrl).calls[channelId];
if (!call) {
return msg;
}
const numParticipants = Object.keys(call.participants).length;
msg = intl.formatMessage({
id: 'mobile.calls_end_msg_channel',
defaultMessage: 'Are you sure you want to end a call with {numParticipants} participants in {displayName}?',
}, {numParticipants, displayName: channel.displayName});
if (channel.type === General.DM_CHANNEL) {
const otherID = getUserIdFromChannelName(currentUserId, channel.name);
const otherUser = await getUserById(database, otherID);
const {config, license} = await getCommonSystemValues(database);
const preferences = await queryPreferencesByCategoryAndName(database, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT).fetch();
const displaySetting = getTeammateNameDisplaySetting(preferences, config, license);
msg = intl.formatMessage({
id: 'mobile.calls_end_msg_dm',
defaultMessage: 'Are you sure you want to end the call with {displayName}?',
}, {displayName: displayUsername(otherUser, intl.locale, displaySetting)});
}
return msg;
};
export const endCall = async (serverUrl: string, channelId: string) => {
const client = NetworkManager.getClient(serverUrl);
let data: ApiResp;
try {
data = await client.endCall(channelId);
} catch (error) {
await forceLogoutIfNecessary(serverUrl, error as ClientError);
throw error;
}
return data;
};

View File

@@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ServerChannelState, ServerCallsConfig} from '@calls/types/calls';
import type {ServerChannelState, ServerCallsConfig, ApiResp} from '@calls/types/calls';
export interface ClientCallsMix {
getEnabled: () => Promise<Boolean>;
getCalls: () => Promise<ServerChannelState[]>;
getCallsConfig: () => Promise<ServerCallsConfig>;
enableChannelCalls: (channelId: string, enable: boolean) => Promise<ServerChannelState>;
endCall: (channelId: string) => Promise<ApiResp>;
}
const ClientCalls = (superclass: any) => class extends superclass {
@@ -43,6 +44,13 @@ const ClientCalls = (superclass: any) => class extends superclass {
{method: 'post', body: {enabled: enable}},
);
};
endCall = async (channelId: string) => {
return this.doFetch(
`${this.getCallsRoute()}/calls/${channelId}/end`,
{method: 'post'},
);
};
};
export default ClientCalls;

View File

@@ -30,7 +30,7 @@ const enhanced = withObservables(['serverUrl', 'channelId'], ({
const callsState = observeCallsState(serverUrl);
const participants = callsState.pipe(
switchMap((state) => of$(state.calls[channelId])),
distinctUntilChanged((prev, curr) => prev.participants === curr.participants), // Did the participants object ref change?
distinctUntilChanged((prev, curr) => prev?.participants === curr?.participants), // Did the participants object ref change?
switchMap((call) => (call ? of$(Object.keys(call.participants)) : of$([]))),
distinctUntilChanged((prev, curr) => idsAreEqual(prev, curr)), // Continue only if we have a different set of participant ids
switchMap((ids) => (ids.length > 0 ? queryUsersById(database, ids).observeWithColumns(['last_picture_update']) : of$([]))),

View File

@@ -1,12 +1,13 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {hasMicrophonePermission, joinCall} from '@calls/actions';
import {errorAlert} from '@calls/utils';
import type {IntlShape} from 'react-intl';
export default function leaveAndJoinWithAlert(
intl: IntlShape,
serverUrl: string,

View File

@@ -4,6 +4,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import {deflate} from 'pako/lib/deflate.js';
import {DeviceEventEmitter, EmitterSubscription} from 'react-native';
import InCallManager from 'react-native-incall-manager';
import {
MediaStream,
@@ -12,6 +13,7 @@ import {
} from 'react-native-webrtc';
import {CallsConnection} from '@calls/types/calls';
import {WebsocketEvents} from '@constants';
import NetworkManager from '@managers/network_manager';
import {logError} from '@utils/log';
@@ -26,6 +28,7 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
let voiceTrackAdded = false;
let voiceTrack: MediaStreamTrack | null = null;
let isClosed = false;
let onCallEnd: EmitterSubscription | null = null;
const streams: MediaStream[] = [];
try {
@@ -53,6 +56,11 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
ws.close();
}
if (onCallEnd) {
onCallEnd.remove();
onCallEnd = null;
}
streams.forEach((s) => {
s.getTracks().forEach((track: MediaStreamTrack) => {
track.stop();
@@ -71,6 +79,12 @@ export async function newConnection(serverUrl: string, channelID: string, closeC
}
};
onCallEnd = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}: { channelId: string }) => {
if (channelId === channelID) {
disconnect();
}
});
const mute = () => {
if (!peer || peer.destroyed) {
return;

View File

@@ -5,9 +5,12 @@ import {DeviceEventEmitter} from 'react-native';
import {fetchUsersByIds} from '@actions/remote/user';
import {
callStarted, setCallScreenOff,
callEnded,
callStarted,
setCallScreenOff,
setCallScreenOn,
setChannelEnabled, setRaisedHand,
setChannelEnabled,
setRaisedHand,
setUserMuted,
userJoinedCall,
userLeftCall,
@@ -60,6 +63,15 @@ export const handleCallStarted = (serverUrl: string, msg: WebSocketMessage) => {
threadId: msg.data.thread_id,
screenOn: '',
participants: {},
ownerId: msg.data.owner_id,
});
};
export const handleCallEnded = (serverUrl: string, msg: WebSocketMessage) => {
callEnded(serverUrl, msg.broadcast.channel_id);
DeviceEventEmitter.emit(WebsocketEvents.CALLS_CALL_END, {
channelId: msg.broadcast.channel_id,
});
};

View File

@@ -357,8 +357,17 @@ const CallScreen = ({componentId, currentCall, participantsDict, teammateNameDis
});
}, [insets, intl, theme]);
useEffect(() => {
const listener = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => {
if (channelId === currentCall?.channelId) {
popTopScreen(componentId);
}
});
return () => listener.remove();
}, []);
if (!currentCall || !myParticipant) {
// This should not be possible, but may happen until https://github.com/mattermost/mattermost-mobile/pull/6493 is merged.
// TODO: will figure out a way to remove the need for this check: https://mattermost.atlassian.net/browse/MM-46050
popTopScreen(componentId);
return null;

View File

@@ -19,7 +19,7 @@ import {
userJoinedCall,
userLeftCall,
callStarted,
callFinished,
callEnded,
setUserMuted,
setCallScreenOn,
setCallScreenOff,
@@ -44,6 +44,7 @@ const call1 = {
startTime: 123,
screenOn: '',
threadId: 'thread-1',
ownerId: 'user-1',
};
const call2 = {
participants: {
@@ -54,6 +55,7 @@ const call2 = {
startTime: 123,
screenOn: '',
threadId: 'thread-2',
ownerId: 'user-3',
};
const call3 = {
participants: {
@@ -64,6 +66,7 @@ const call3 = {
startTime: 123,
screenOn: '',
threadId: 'thread-3',
ownerId: 'user-5',
};
describe('useCallsState', () => {
@@ -165,6 +168,7 @@ describe('useCallsState', () => {
startTime: 123,
screenOn: '',
threadId: 'thread-1',
ownerId: 'user-1',
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@@ -221,6 +225,7 @@ describe('useCallsState', () => {
startTime: 123,
screenOn: '',
threadId: 'thread-1',
ownerId: 'user-1',
},
};
const expectedChannelsWithCallsState = initialChannelsWithCallsState;
@@ -269,8 +274,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[2], null);
});
// TODO: needs to be changed to callEnd when that ws event is implemented
it('callFinished', () => {
it('callEnded', () => {
const initialCallsState = {
...DefaultCallsState,
calls: {'channel-1': call1, 'channel-2': call2},
@@ -295,7 +299,7 @@ describe('useCallsState', () => {
assert.deepEqual(result.current[2], null);
// test
act(() => callFinished('server1', 'channel-1'));
act(() => callEnded('server1', 'channel-1'));
assert.deepEqual(result.current[0], expectedCallsState);
assert.deepEqual(result.current[1], expectedChannelsWithCallsState);
assert.deepEqual(result.current[2], null);
@@ -409,6 +413,7 @@ describe('useCallsState', () => {
startTime: 123,
screenOn: false,
threadId: 'thread-1',
ownerId: 'user-1',
},
};
const initialCurrentCallState = {

View File

@@ -136,8 +136,7 @@ export const callStarted = (serverUrl: string, call: Call) => {
setCurrentCall(nextCurrentCall);
};
// TODO: should be called callEnded to match the ws event. Will fix when callEnded is implemented.
export const callFinished = (serverUrl: string, channelId: string) => {
export const callEnded = (serverUrl: string, channelId: string) => {
const callsState = getCallsState(serverUrl);
const nextCalls = {...callsState.calls};
delete nextCalls[channelId];
@@ -147,6 +146,11 @@ export const callFinished = (serverUrl: string, channelId: string) => {
const nextChannelsWithCalls = {...channelsWithCalls};
delete nextChannelsWithCalls[channelId];
setChannelsWithCalls(serverUrl, nextChannelsWithCalls);
const currentCall = getCurrentCall();
if (currentCall?.channelId === channelId) {
setCurrentCall(null);
}
};
export const setUserMuted = (serverUrl: string, channelId: string, userId: string, muted: boolean) => {

View File

@@ -23,6 +23,7 @@ export type Call = {
startTime: number;
screenOn: string;
threadId: string;
ownerId: string;
}
export const DefaultCall = {
@@ -72,6 +73,7 @@ export type ServerCallState = {
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
owner_id: string;
}
export type VoiceEventData = {
@@ -109,3 +111,9 @@ export const DefaultCallsConfig = {
DefaultEnabled: false,
last_retrieved_at: 0,
} as CallsConfig;
export type ApiResp = {
message?: string;
detailed_error?: string;
status_code: number;
}

View File

@@ -1,7 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntlShape} from 'react-intl';
import {Alert} from 'react-native';
import {CallParticipant} from '@calls/types/calls';
@@ -11,6 +10,8 @@ import PostModel from '@typings/database/models/servers/post';
import {isMinimumServerVersion} from '@utils/helpers';
import {displayUsername} from '@utils/user';
import type {IntlShape} from 'react-intl';
export function sortParticipants(teammateNameDisplay: string, participants?: Dictionary<CallParticipant>, presenterID?: string): CallParticipant[] {
if (!participants) {
return [];

View File

@@ -357,9 +357,16 @@
"mobile.android.back_handler_exit": "Press back again to exit",
"mobile.android.photos_permission_denied_description": "Upload photos to your server or save them to your device. Open Settings to grant {applicationName} Read and Write access to your photo library.",
"mobile.android.photos_permission_denied_title": "{applicationName} would like to access your photos",
"mobile.calls_call_screen": "Call",
"mobile.calls_disable": "Disable Calls",
"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}?",
"mobile.calls_end_msg_dm": "Are you sure you want to end a call with {displayName}?",
"mobile.calls_end_permission_msg": "You do not have permission to end the call. Please ask the call creator to end the call.",
"mobile.calls_end_permission_title": "Error",
"mobile.calls_error_message": "Error: {error}",
"mobile.calls_error_permissions": "no permissions to microphone, unable to start call",
"mobile.calls_error_title": "Error",
"mobile.calls_join_call": "Join Call",
"mobile.calls_leave_call": "Leave Call",