From 23509cbb839cf3f74e00cd6602be276c82e0810f Mon Sep 17 00:00:00 2001 From: Claudio Costa Date: Thu, 2 Jun 2022 10:59:11 +0200 Subject: [PATCH] [MM-44155] Handle call_end event (#6316) * Handle call_end event * exit call screen on call end; /call end for mobile * handle permissions before sending cmd to server; handle error Co-authored-by: Christopher Poile --- app/actions/websocket/index.ts | 3 + .../post_draft/draft_input/draft_input.js | 7 +++ .../post_draft/draft_input/index.js | 2 + app/constants/websocket.ts | 1 + app/products/calls/client/rest.ts | 8 +++ app/products/calls/connection.ts | 14 +++++ .../calls/screens/call/call_screen.tsx | 10 +++ .../calls/store/action_types/calls.ts | 1 + app/products/calls/store/actions/calls.ts | 62 ++++++++++++++++++- .../calls/store/actions/websockets.ts | 10 ++- .../calls/store/reducers/calls.test.js | 10 +++ app/products/calls/store/reducers/calls.ts | 11 ++++ app/products/calls/store/selectors/calls.ts | 24 +++++-- app/products/calls/store/types/calls.ts | 4 +- app/products/calls/utils.ts | 11 ++++ ios/Podfile.lock | 6 +- 16 files changed, 172 insertions(+), 12 deletions(-) diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 4da6f91de..03d0c88a4 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -27,6 +27,7 @@ import {removeUserFromList} from '@mm-redux/utils/user_utils'; import {batchLoadCalls} from '@mmproducts/calls/store/actions/calls'; import { handleCallStarted, + handleCallEnded, handleCallUserConnected, handleCallUserDisconnected, handleCallUserMuted, @@ -473,6 +474,8 @@ function handleEvent(msg: WebSocketMessage) { break; case WebsocketEvents.CALLS_CALL_START: return dispatch(handleCallStarted(msg)); + case WebsocketEvents.CALLS_CALL_END: + return dispatch(handleCallEnded(msg)); case WebsocketEvents.CALLS_SCREEN_ON: return dispatch(handleCallScreenOn(msg)); case WebsocketEvents.CALLS_SCREEN_OFF: diff --git a/app/components/post_draft/draft_input/draft_input.js b/app/components/post_draft/draft_input/draft_input.js index aaa5f867e..f46628744 100644 --- a/app/components/post_draft/draft_input/draft_input.js +++ b/app/components/post_draft/draft_input/draft_input.js @@ -63,6 +63,7 @@ export default class DraftInput extends PureComponent { channelMemberCountsByGroup: PropTypes.object, groupsWithAllowReference: PropTypes.object, addRecentUsedEmojisInMessage: PropTypes.func.isRequired, + endCallAlert: PropTypes.func.isRequired, }; static defaultProps = { @@ -296,6 +297,12 @@ export default class DraftInput extends PureComponent { const {intl} = this.context; const {channelId, executeCommand, rootId, userIsOutOfOffice, theme} = this.props; + if (msg.trim() === '/call end') { + this.props.endCallAlert(channelId); + + // NOTE: fallthrough because the server may want to handle the command as well + } + const status = DraftUtils.getStatusFromSlashCommand(msg); if (userIsOutOfOffice && DraftUtils.isStatusSlashCommand(status)) { confirmOutOfOfficeDisabled(intl, status, this.updateStatus); diff --git a/app/components/post_draft/draft_input/index.js b/app/components/post_draft/draft_input/index.js index a4c6652f7..4d9c03b10 100644 --- a/app/components/post_draft/draft_input/index.js +++ b/app/components/post_draft/draft_input/index.js @@ -18,6 +18,7 @@ import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/g import {getTheme} from '@mm-redux/selectors/entities/preferences'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users'; +import {endCallAlert} from '@mmproducts/calls/store/actions/calls'; import {isLandscape} from '@selectors/device'; import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views'; @@ -103,6 +104,7 @@ const mapDispatchToProps = { setStatus, getChannelMemberCountsByGroup, addRecentUsedEmojisInMessage, + endCallAlert, }; export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft); diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index 11004aca6..ab172c509 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -65,6 +65,7 @@ const WebsocketEvents = { CALLS_USER_VOICE_ON: `custom_${Calls.PluginId}_user_voice_on`, CALLS_USER_VOICE_OFF: `custom_${Calls.PluginId}_user_voice_off`, CALLS_CALL_START: `custom_${Calls.PluginId}_call_start`, + CALLS_CALL_END: `custom_${Calls.PluginId}_call_end`, CALLS_SCREEN_ON: `custom_${Calls.PluginId}_user_screen_on`, CALLS_SCREEN_OFF: `custom_${Calls.PluginId}_user_screen_off`, CALLS_USER_RAISE_HAND: `custom_${Calls.PluginId}_user_raise_hand`, diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 164f6826f..84742b62e 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -9,6 +9,7 @@ export interface ClientCallsMix { getCallsConfig: () => Promise; enableChannelCalls: (channelId: string) => Promise; disableChannelCalls: (channelId: string) => Promise; + endCall: (channelId: string) => Promise; } const ClientCalls = (superclass: any) => class extends superclass { @@ -51,6 +52,13 @@ const ClientCalls = (superclass: any) => class extends superclass { {method: 'post', body: JSON.stringify({enabled: false})}, ); }; + + endCall = async (channelId: string) => { + return this.doFetch( + `${this.getCallsRoute()}/calls/${channelId}/end`, + {method: 'post'}, + ); + }; }; export default ClientCalls; diff --git a/app/products/calls/connection.ts b/app/products/calls/connection.ts index 3c681f966..c982dad8a 100644 --- a/app/products/calls/connection.ts +++ b/app/products/calls/connection.ts @@ -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 {Client4} from '@client/rest'; +import {WebsocketEvents} from '@constants'; import Peer from './simple-peer'; import WebSocketClient from './websocket'; @@ -26,6 +28,7 @@ export async function newClient(channelID: string, iceServers: string[], closeCb let voiceTrackAdded = false; let voiceTrack: MediaStreamTrack | null = null; let isClosed = false; + let onCallEnd: EmitterSubscription | null = null; const streams: MediaStream[] = []; try { @@ -47,6 +50,11 @@ export async function newClient(channelID: string, iceServers: string[], closeCb ws.close(); } + if (onCallEnd) { + onCallEnd.remove(); + onCallEnd = null; + } + streams.forEach((s) => { s.getTracks().forEach((track: MediaStreamTrack) => { track.stop(); @@ -65,6 +73,12 @@ export async function newClient(channelID: string, iceServers: string[], closeCb } }; + onCallEnd = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => { + if (channelId === channelID) { + disconnect(); + } + }); + const mute = () => { if (!peer) { return; diff --git a/app/products/calls/screens/call/call_screen.tsx b/app/products/calls/screens/call/call_screen.tsx index 21606fefe..a2a2f4a73 100644 --- a/app/products/calls/screens/call/call_screen.tsx +++ b/app/products/calls/screens/call/call_screen.tsx @@ -323,6 +323,16 @@ const CallScreen = (props: Props) => { setShowControlsInLandscape(!showControlsInLandscape); }, [showControlsInLandscape]); + useEffect(() => { + const listener = DeviceEventEmitter.addListener(WebsocketEvents.CALLS_CALL_END, ({channelId}) => { + if (channelId === props.call?.channelId) { + popTopScreen(); + } + }); + + return () => listener.remove(); + }, []); + if (!props.call) { return null; } diff --git a/app/products/calls/store/action_types/calls.ts b/app/products/calls/store/action_types/calls.ts index 753880754..f19429588 100644 --- a/app/products/calls/store/action_types/calls.ts +++ b/app/products/calls/store/action_types/calls.ts @@ -6,6 +6,7 @@ import keyMirror from '@mm-redux/utils/key_mirror'; export default keyMirror({ RECEIVED_CALLS: null, RECEIVED_CALL_STARTED: null, + RECEIVED_CALL_ENDED: null, RECEIVED_CALL_FINISHED: null, RECEIVED_CHANNEL_CALL_ENABLED: null, RECEIVED_CHANNEL_CALL_DISABLED: null, diff --git a/app/products/calls/store/actions/calls.ts b/app/products/calls/store/actions/calls.ts index 3f79d772b..931e19d15 100644 --- a/app/products/calls/store/actions/calls.ts +++ b/app/products/calls/store/actions/calls.ts @@ -2,6 +2,7 @@ // See LICENSE.txt for license information. import {intlShape} from 'react-intl'; +import {Alert} from 'react-native'; import InCallManager from 'react-native-incall-manager'; import {batch} from 'react-redux'; @@ -9,6 +10,10 @@ import {Client4} from '@client/rest'; import Calls from '@constants/calls'; import {logError} from '@mm-redux/actions/errors'; import {forceLogoutIfNecessary} from '@mm-redux/actions/helpers'; +import {General} from '@mm-redux/constants'; +import {getCurrentChannel} from '@mm-redux/selectors/entities/channels'; +import {getTeammateNameDisplaySetting} from '@mm-redux/selectors/entities/preferences'; +import {getCurrentUserId, getCurrentUserRoles, getUser} from '@mm-redux/selectors/entities/users'; import { GenericAction, ActionFunc, @@ -17,10 +22,16 @@ import { ActionResult, } from '@mm-redux/types/actions'; import {Dictionary} from '@mm-redux/types/utilities'; +import {displayUsername, isAdmin as checkIsAdmin} from '@mm-redux/utils/user_utils'; import {newClient} from '@mmproducts/calls/connection'; import CallsTypes from '@mmproducts/calls/store/action_types/calls'; -import {getConfig} from '@mmproducts/calls/store/selectors/calls'; +import { + getCallInCurrentChannel, + getConfig, + getNumCurrentConnectedParticipants, +} from '@mmproducts/calls/store/selectors/calls'; import {Call, CallParticipant, DefaultServerConfig} from '@mmproducts/calls/store/types/calls'; +import {getUserIdFromDM} from '@mmproducts/calls/utils'; import {hasMicrophonePermission} from '@utils/permission'; export let ws: any = null; @@ -82,6 +93,7 @@ export function loadCalls(): ActionFunc { speakers: [], screenOn: channel.call.screen_sharing_id, threadId: channel.call.thread_id, + creatorId: channel.call.creator_id, }; } enabledChannels[channel.channel_id] = channel.enabled; @@ -261,3 +273,51 @@ export function setSpeakerphoneOn(newState: boolean): GenericAction { data: newState, }; } + +export function endCallAlert(channelId: string): ActionFunc { + return async (dispatch: DispatchFunc, getState: GetStateFunc) => { + const userId = getCurrentUserId(getState()); + const numParticipants = getNumCurrentConnectedParticipants(getState()); + const channel = getCurrentChannel(getState()); + const currentCall = getCallInCurrentChannel(getState()); + const roles = getCurrentUserRoles(getState()); + const isAdmin = checkIsAdmin(roles); + + if (!isAdmin && userId !== currentCall?.creatorId) { + Alert.alert('Error', 'You do not have permission to end the call. Please ask the call creator to end call.'); + return {}; + } + + let msg = `Are you sure you want to end a call with ${numParticipants} participants in ${channel.display_name}?`; + if (channel.type === General.DM_CHANNEL) { + const otherID = getUserIdFromDM(channel.name, getCurrentUserId(getState())); + const otherUser = getUser(getState(), otherID); + const nameDisplay = getTeammateNameDisplaySetting(getState()); + msg = `Are you sure you want to end a call with ${displayUsername(otherUser, nameDisplay)}?`; + } + + Alert.alert( + 'End call', + msg, + [ + { + text: 'Cancel', + }, + { + text: 'End call', + onPress: async () => { + try { + await Client4.endCall(channelId); + } catch (e) { + const err = e.message || 'unable to complete command, see server logs'; + Alert.alert('Error', `Error: ${err}`); + } + }, + style: 'cancel', + }, + ], + ); + + return {}; + }; +} diff --git a/app/products/calls/store/actions/websockets.ts b/app/products/calls/store/actions/websockets.ts index 1739696dc..b69b297d1 100644 --- a/app/products/calls/store/actions/websockets.ts +++ b/app/products/calls/store/actions/websockets.ts @@ -50,7 +50,15 @@ export function handleCallUserVoiceOff(msg: WebSocketMessage) { export function handleCallStarted(msg: WebSocketMessage): GenericAction { return { type: CallsTypes.RECEIVED_CALL_STARTED, - data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}}, + data: {channelId: msg.data.channelID, startTime: msg.data.start_at, threadId: msg.data.thread_id, participants: {}, creatorId: msg.data.creator_id}, + }; +} + +export function handleCallEnded(msg: WebSocketMessage): GenericAction { + DeviceEventEmitter.emit(WebsocketEvents.CALLS_CALL_END, {channelId: msg.broadcast.channel_id}); + return { + type: CallsTypes.RECEIVED_CALL_ENDED, + data: {channelId: msg.broadcast.channel_id}, }; } diff --git a/app/products/calls/store/reducers/calls.test.js b/app/products/calls/store/reducers/calls.test.js index e7cf06eac..170152b33 100644 --- a/app/products/calls/store/reducers/calls.test.js +++ b/app/products/calls/store/reducers/calls.test.js @@ -145,6 +145,16 @@ describe('Reducers.calls.calls', () => { assert.deepEqual(state.calls, {'channel-2': call2}); }); + it('RECEIVED_CALL_ENDED', async () => { + const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; + const testAction = { + type: CallsTypes.RECEIVED_CALL_FINISHED, + data: {channelId: 'channel-1'}, + }; + const state = callsReducer(initialState, testAction); + assert.deepEqual(state.calls, {'channel-2': call2}); + }); + it('RECEIVED_MUTE_USER_CALL', async () => { const initialState = {calls: {'channel-1': call1, 'channel-2': call2}}; const testAction = { diff --git a/app/products/calls/store/reducers/calls.ts b/app/products/calls/store/reducers/calls.ts index b2b61775a..d213ad44c 100644 --- a/app/products/calls/store/reducers/calls.ts +++ b/app/products/calls/store/reducers/calls.ts @@ -53,6 +53,11 @@ function calls(state: Dictionary = {}, action: GenericAction) { nextState[newCall.channelId] = newCall; return nextState; } + case CallsTypes.RECEIVED_CALL_ENDED: { + const nextState = {...state}; + delete nextState[action.data.channelId]; + return nextState; + } case CallsTypes.RECEIVED_CALL_FINISHED: { const newCall = action.data; const nextState = {...state}; @@ -162,6 +167,12 @@ function joined(state = '', action: GenericAction) { case CallsTypes.RECEIVED_MYSELF_JOINED_CALL: { return action.data; } + case CallsTypes.RECEIVED_CALL_ENDED: { + if (action.data.channelId === state) { + return ''; + } + return state; + } case CallsTypes.RECEIVED_MYSELF_LEFT_CALL: { return ''; } diff --git a/app/products/calls/store/selectors/calls.ts b/app/products/calls/store/selectors/calls.ts index 12f7f288d..9c5828b6b 100644 --- a/app/products/calls/store/selectors/calls.ts +++ b/app/products/calls/store/selectors/calls.ts @@ -69,6 +69,24 @@ export function isCallsPluginEnabled(state: GlobalState) { return state.entities.calls.pluginEnabled; } +export const getCallInCurrentChannel: (state: GlobalState) => Call | undefined = createSelector( + getCurrentChannelId, + getCalls, + (currentChannelId, calls) => calls[currentChannelId], +); + +export const getNumCurrentConnectedParticipants: (state: GlobalState) => number = createSelector( + getCurrentChannelId, + getCalls, + (currentChannelId, calls) => { + const participants = calls[currentChannelId]?.participants; + if (!participants) { + return 0; + } + return Object.keys(participants).length || 0; + }, +); + const isCloud: (state: GlobalState) => boolean = createSelector( getLicense, (license) => license?.Cloud === 'true', @@ -84,12 +102,6 @@ const isCloudProfessionalOrEnterprise: (state: GlobalState) => boolean = createS }, ); -const getCallInCurrentChannel: (state: GlobalState) => Call | undefined = createSelector( - getCurrentChannelId, - getCalls, - (currentChannelId, calls) => calls[currentChannelId], -); - export const isCloudLimitRestricted: (state: GlobalState, channelId?: string) => boolean = createSelector( isCloud, isCloudProfessionalOrEnterprise, diff --git a/app/products/calls/store/types/calls.ts b/app/products/calls/store/types/calls.ts index 2f609c86d..fb3b85f42 100644 --- a/app/products/calls/store/types/calls.ts +++ b/app/products/calls/store/types/calls.ts @@ -15,12 +15,13 @@ export type CallsState = { } export type Call = { - participants: Dictionary; + participants: Dictionary; channelId: string; startTime: number; speakers: string[]; screenOn: string; threadId: string; + creatorId: string; } export type CallParticipant = { @@ -49,6 +50,7 @@ export type ServerCallState = { states: ServerUserState[]; thread_id: string; screen_sharing_id: string; + creator_id: string; } export type VoiceEventData = { diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index 3c9a0d261..482457473 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -48,3 +48,14 @@ const sortByState = (presenterID?: string) => { return 0; }; }; + +export function getUserIdFromDM(dmName: string, currentUserId: string) { + const ids = dmName.split('__'); + let otherUserId = ''; + if (ids[0] === currentUserId) { + otherUserId = ids[1]; + } else { + otherUserId = ids[0]; + } + return otherUserId; +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 5e0472aab..0c6ce1d74 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -733,7 +733,7 @@ SPEC CHECKSUMS: FBLazyVector: 244195e30d63d7f564c55da4410b9a24e8fbceaa FBReactNativeSpec: c94002c1d93da3658f4d5119c6994d19961e3d52 fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9 - glog: 5337263514dd6f09803962437687240c5dc39aa4 + glog: 85ecdd10ee8d8ec362ef519a6a45ff9aa27b2e85 HMSegmentedControl: 34c1f54d822d8308e7b24f5d901ec674dfa31352 iosMath: f7a6cbadf9d836d2149c2a84c435b1effc244cba jail-monkey: 07b83767601a373db876e939b8dbf3f5eb15f073 @@ -746,7 +746,7 @@ SPEC CHECKSUMS: Permission-Notifications: bb420c3d28328df24de1b476b41ed8249ccf2537 Permission-PhotoLibrary: 7bec836dcdd04a0bfb200c314f1aae06d4476357 Permission-PhotoLibraryAddOnly: 06fb0cdb1d35683b235ad8c464ef0ecc88859ea3 - RCT-Folly: a21c126816d8025b547704b777a2ba552f3d9fa9 + RCT-Folly: 803a9cfd78114b2ec0f140cfa6fa2a6bafb2d685 RCTRequired: cd47794163052d2b8318c891a7a14fcfaccc75ab RCTTypeSafety: 393bb40b3e357b224cde53d3fec26813c52428b1 RCTYouTube: a8bb45705622a6fc9decf64be04128d3658ed411 @@ -824,4 +824,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 8214414d5676358401d8ad51dff19e7fd8c71b5c -COCOAPODS: 1.11.2 +COCOAPODS: 1.11.3