[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 <cpoile@gmail.com>
This commit is contained in:
parent
c74cd14713
commit
23509cbb83
16 changed files with 172 additions and 12 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
@ -49,6 +50,7 @@ export type ServerCallState = {
|
|||
states: ServerUserState[];
|
||||
thread_id: string;
|
||||
screen_sharing_id: string;
|
||||
creator_id: string;
|
||||
}
|
||||
|
||||
export type VoiceEventData = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue