Skip to content

Commit

Permalink
[MM-44155] Handle call_end event (mattermost#6316)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
streamer45 and cpoile authored Jun 2, 2022
1 parent c74cd14 commit 23509cb
Show file tree
Hide file tree
Showing 16 changed files with 172 additions and 12 deletions.
3 changes: 3 additions & 0 deletions app/actions/websocket/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions app/components/post_draft/draft_input/draft_input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions app/components/post_draft/draft_input/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -103,6 +104,7 @@ const mapDispatchToProps = {
setStatus,
getChannelMemberCountsByGroup,
addRecentUsedEmojisInMessage,
endCallAlert,
};

export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft);
1 change: 1 addition & 0 deletions app/constants/websocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
8 changes: 8 additions & 0 deletions app/products/calls/client/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface ClientCallsMix {
getCallsConfig: () => Promise<ServerConfig>;
enableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
disableChannelCalls: (channelId: string) => Promise<ServerChannelState>;
endCall: (channelId: string) => Promise<any>;
}

const ClientCalls = (superclass: any) => class extends superclass {
Expand Down Expand Up @@ -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;
14 changes: 14 additions & 0 deletions app/products/calls/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand All @@ -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 {
Expand All @@ -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();
Expand All @@ -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;
Expand Down
10 changes: 10 additions & 0 deletions app/products/calls/screens/call/call_screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions app/products/calls/store/action_types/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
62 changes: 61 additions & 1 deletion app/products/calls/store/actions/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
// 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';

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,
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 {};
};
}
10 changes: 9 additions & 1 deletion app/products/calls/store/actions/websockets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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},
};
}

Expand Down
10 changes: 10 additions & 0 deletions app/products/calls/store/reducers/calls.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 11 additions & 0 deletions app/products/calls/store/reducers/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ function calls(state: Dictionary<Call> = {}, 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};
Expand Down Expand Up @@ -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 '';
}
Expand Down
24 changes: 18 additions & 6 deletions app/products/calls/store/selectors/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion app/products/calls/store/types/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@ export type CallsState = {
}

export type Call = {
participants: Dictionary<CallParticipant>;
participants: Dictionary<CallParticipant>;
channelId: string;
startTime: number;
speakers: string[];
screenOn: string;
threadId: string;
creatorId: string;
}

export type CallParticipant = {
Expand Down Expand Up @@ -49,6 +50,7 @@ export type ServerCallState = {
states: ServerUserState[];
thread_id: string;
screen_sharing_id: string;
creator_id: string;
}

export type VoiceEventData = {
Expand Down
11 changes: 11 additions & 0 deletions app/products/calls/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading

0 comments on commit 23509cb

Please sign in to comment.