diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 29ff75e65..0d5cec0e8 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -36,6 +36,7 @@ import { handleCallUserVoiceOn, handleHostLowerHand, handleHostMute, + handleHostRemoved, handleUserDismissedNotification, } from '@calls/connection/websocket_event_handlers'; import {isSupportedServerCalls} from '@calls/utils'; @@ -461,6 +462,9 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) { case WebsocketEvents.CALLS_HOST_LOWER_HAND: handleHostLowerHand(serverUrl, msg); break; + case WebsocketEvents.CALLS_HOST_REMOVED: + handleHostRemoved(serverUrl, msg); + break; case WebsocketEvents.GROUP_RECEIVED: handleGroupReceivedEvent(serverUrl, msg); diff --git a/app/constants/websocket.ts b/app/constants/websocket.ts index d1594b9ff..4b75b4631 100644 --- a/app/constants/websocket.ts +++ b/app/constants/websocket.ts @@ -90,6 +90,7 @@ const WebsocketEvents = { CALLS_CAPTION: `custom_${Calls.PluginId}_caption`, CALLS_HOST_MUTE: `custom_${Calls.PluginId}_host_mute`, CALLS_HOST_LOWER_HAND: `custom_${Calls.PluginId}_host_lower_hand`, + CALLS_HOST_REMOVED: `custom_${Calls.PluginId}_host_removed`, GROUP_RECEIVED: 'received_group', GROUP_MEMBER_ADD: 'group_member_add', diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index db970f933..8dccf148c 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -664,3 +664,13 @@ export const hostLowerHand = async (serverUrl: string, callId: string, sessionId } }; +export const hostRemove = async (serverUrl: string, callId: string, sessionId: string) => { + try { + const client = NetworkManager.getClient(serverUrl); + return client.hostRemove(callId, sessionId); + } catch (error) { + logDebug('error on hostRemove', getFullErrorMessage(error)); + await forceLogoutIfNecessary(serverUrl, error); + return error; + } +}; diff --git a/app/products/calls/alerts.ts b/app/products/calls/alerts.ts index 9df53b23a..58b0a0938 100644 --- a/app/products/calls/alerts.ts +++ b/app/products/calls/alerts.ts @@ -4,9 +4,9 @@ import {Alert} from 'react-native'; import {hasMicrophonePermission, joinCall, leaveCall, unmuteMyself} from '@calls/actions'; -import {dismissIncomingCall} from '@calls/actions/calls'; +import {dismissIncomingCall, hostRemove} from '@calls/actions/calls'; import {hasBluetoothPermission} from '@calls/actions/permissions'; -import {userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors'; +import {hostRemovedErr, userLeftChannelErr, userRemovedFromChannelErr} from '@calls/errors'; import { getCallsConfig, getCallsState, @@ -19,6 +19,7 @@ import {errorAlert} from '@calls/utils'; import DatabaseManager from '@database/manager'; import {getChannelById} from '@queries/servers/channel'; import {getCurrentUser} from '@queries/servers/user'; +import {dismissBottomSheet} from '@screens/navigation'; import {isDMorGM} from '@utils/channel'; import {getFullErrorMessage} from '@utils/errors'; import {logError} from '@utils/log'; @@ -413,8 +414,55 @@ export const showErrorAlertOnClose = (err: Error, intl: IntlShape) => { }), ); break; + case hostRemovedErr: + Alert.alert( + intl.formatMessage({ + id: 'mobile.calls_removed_alert_title', + defaultMessage: 'You were removed from the call', + }), + intl.formatMessage({ + id: 'mobile.calls_removed_alert_body', + defaultMessage: 'The host removed you from the call.', + }), + [{ + text: intl.formatMessage({ + id: 'mobile.calls_dismiss', + defaultMessage: 'Dismiss', + }), + }]); + break; default: // Fallback with generic error errorAlert(getFullErrorMessage(err, intl), intl); } }; + +export const removeFromCall = (serverUrl: string, displayName: string, callId: string, sessionId: string, intl: IntlShape) => { + const {formatMessage} = intl; + + const title = formatMessage({ + id: 'mobile.calls_remove_alert_title', + defaultMessage: 'Remove participant', + }); + const body = formatMessage({ + id: 'mobile.calls_remove_alert_body', + defaultMessage: 'Are you sure you want to remove {displayName} from the call? ', + }, {displayName}); + + Alert.alert(title, body, [{ + text: formatMessage({ + id: 'mobile.post.cancel', + defaultMessage: 'Cancel', + }), + }, { + text: formatMessage({ + id: 'mobile.calls_remove', + defaultMessage: 'Remove', + }), + onPress: () => { + hostRemove(serverUrl, callId, sessionId); + dismissBottomSheet(); + }, + style: 'destructive', + }]); +}; diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 19a4a235e..4c3c0800b 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -21,6 +21,7 @@ export interface ClientCallsMix { hostMute: (callId: string, sessionId: string) => Promise; hostScreenOff: (callId: string, sessionId: string) => Promise; hostLowerHand: (callId: string, sessionId: string) => Promise; + hostRemove: (callId: string, sessionId: string) => Promise; } const ClientCalls = (superclass: any) => class extends superclass { @@ -149,6 +150,16 @@ const ClientCalls = (superclass: any) => class extends superclass { }, ); }; + + hostRemove = async (callId: string, sessionId: string) => { + return this.doFetch( + `${this.getCallsRoute()}/calls/${callId}/host/remove`, + { + method: 'post', + body: {session_id: sessionId}, + }, + ); + }; }; export default ClientCalls; diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index a7938d9df..6749ca0c6 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -4,7 +4,8 @@ import {DeviceEventEmitter} from 'react-native'; import {fetchUsersByIds} from '@actions/remote/user'; -import {muteMyself, unraiseHand} from '@calls/actions'; +import {leaveCall, muteMyself, unraiseHand} from '@calls/actions'; +import {hostRemovedErr} from '@calls/errors'; import { callEnded, callStarted, @@ -228,3 +229,14 @@ export const handleHostLowerHand = async (serverUrl: string, msg: WebSocketMessa unraiseHand(); }; + +export const handleHostRemoved = async (serverUrl: string, msg: WebSocketMessage) => { + const currentCall = getCurrentCall(); + if (currentCall?.serverUrl !== serverUrl || + currentCall?.channelId !== msg.data.channel_id || + currentCall?.mySessionId !== msg.data.session_id) { + return; + } + + leaveCall(hostRemovedErr); +}; diff --git a/app/products/calls/errors.ts b/app/products/calls/errors.ts index 2fea518a8..4ca6af935 100644 --- a/app/products/calls/errors.ts +++ b/app/products/calls/errors.ts @@ -3,3 +3,4 @@ export const userRemovedFromChannelErr = new Error('user was removed from channel'); export const userLeftChannelErr = new Error('user has left channel'); +export const hostRemovedErr = new Error('user was removed by host'); diff --git a/app/products/calls/screens/host_controls/host_controls.tsx b/app/products/calls/screens/host_controls/host_controls.tsx index fd750816d..accf1c23b 100644 --- a/app/products/calls/screens/host_controls/host_controls.tsx +++ b/app/products/calls/screens/host_controls/host_controls.tsx @@ -3,21 +3,28 @@ import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; -import {StyleSheet} from 'react-native'; +import {Platform, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import {hostLowerHand, hostMake, hostMuteSession, hostStopScreenshare} from '@calls/actions/calls'; +import {removeFromCall} from '@calls/alerts'; +import {useHostMenus} from '@calls/hooks'; import SlideUpPanelItem from '@components/slide_up_panel_item'; import {Screens} from '@constants'; +import {useTheme} from '@context/theme'; import BottomSheet from '@screens/bottom_sheet'; import {dismissBottomSheet} from '@screens/navigation'; import UserProfileTitle from '@screens/user_profile/title'; import {bottomSheetSnapPoint} from '@utils/helpers'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import {displayUsername} from '@utils/user'; import type {CallSession, CurrentCall} from '@calls/types/calls'; const TITLE_HEIGHT = 118; const ITEM_HEIGHT = 48; +const SEPARATOR_HEIGHT = 17; +const ANDROID_BUMP_HEIGHT = 20; type Props = { currentCall: CurrentCall; @@ -27,11 +34,18 @@ type Props = { hideGuestTags: boolean; } -const styles = StyleSheet.create({ +const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => ({ iconStyle: { marginRight: 6, }, -}); + separator: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.2), + height: 1, + width: '100%', + alignSelf: 'center', + marginVertical: 8, + }, +})); export const HostControls = ({ currentCall, @@ -41,7 +55,10 @@ export const HostControls = ({ hideGuestTags, }: Props) => { const intl = useIntl(); + const theme = useTheme(); const {bottom} = useSafeAreaInsets(); + const {openUserProfile} = useHostMenus(); + const styles = getStyleFromTheme(theme); const sharingScreen = currentCall.screenOn === session.sessionId; @@ -63,20 +80,35 @@ export const HostControls = ({ const stopScreensharePress = useCallback(async () => { hostStopScreenshare(currentCall.serverUrl, currentCall.channelId, session.sessionId); await dismissBottomSheet(); - }, [currentCall.serverUrl, currentCall.id, session.sessionId]); + }, [currentCall.serverUrl, currentCall.channelId, session.sessionId]); + + const profilePress = useCallback(async () => { + await dismissBottomSheet(); + openUserProfile(session); + }, [session]); + + const removePress = useCallback(async () => { + const displayName = displayUsername(session.userModel, intl.locale, teammateNameDisplay, true); + removeFromCall(currentCall.serverUrl, displayName, currentCall.channelId, session.sessionId, intl); + }, [session.userModel, intl, teammateNameDisplay, currentCall.serverUrl, currentCall.channelId, session.sessionId]); const snapPoints = useMemo(() => { - const items = 1 + (session.muted ? 0 : 1) + (sharingScreen ? 1 : 0) + (session.raisedHand ? 1 : 0); + const items = 3 + (session.muted ? 0 : 1) + (sharingScreen ? 1 : 0) + (session.raisedHand ? 1 : 0); return [ 1, - bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom) + TITLE_HEIGHT, + bottomSheetSnapPoint(items, ITEM_HEIGHT, bottom) + TITLE_HEIGHT + SEPARATOR_HEIGHT + (Platform.OS === 'android' ? ANDROID_BUMP_HEIGHT : 0), ]; }, [bottom, session.muted, sharingScreen, session.raisedHand]); const makeHostText = intl.formatMessage({id: 'mobile.calls_make_host', defaultMessage: 'Make host'}); const muteText = intl.formatMessage({id: 'mobile.calls_mute_participant', defaultMessage: 'Mute participant'}); const lowerHandText = intl.formatMessage({id: 'mobile.calls_lower_hand', defaultMessage: 'Lower hand'}); - const stopScreenshareText = intl.formatMessage({id: 'mobile.calls_stop_screenshare', defaultMessage: 'Stop screen share'}); + const stopScreenshareText = intl.formatMessage({ + id: 'mobile.calls_stop_screenshare', + defaultMessage: 'Stop screen share', + }); + const profileText = intl.formatMessage({id: 'mobile.calls_view_profile', defaultMessage: 'View profile'}); + const removeText = intl.formatMessage({id: 'mobile.calls_remove_participant', defaultMessage: 'Remove from call'}); const renderContent = () => { if (!session?.userModel) { @@ -125,6 +157,20 @@ export const HostControls = ({ onPress={makeHostPress} text={makeHostText} /> + + + ); }; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 51004b7a1..3d1c5e322 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -515,6 +515,12 @@ "mobile.calls_recording_start_no_permissions": "You don't have permissions to start a recording. Please ask the call host to start a recording.", "mobile.calls_recording_stop_no_permissions": "You don't have permissions to stop the recording. Please ask the call host to stop the recording.", "mobile.calls_recording_stop_none_in_progress": "No recording is in progress.", + "mobile.calls_remove": "Remove", + "mobile.calls_remove_alert_body": "Are you sure you want to remove {displayName} from the call? ", + "mobile.calls_remove_alert_title": "Remove participant", + "mobile.calls_remove_participant": "Remove from call", + "mobile.calls_removed_alert_body": "The host removed you from the call.", + "mobile.calls_removed_alert_title": "You were removed from the call", "mobile.calls_request_message": "Calls are currently running in test mode and only system admins can start them. Reach out directly to your system admin for assistance", "mobile.calls_request_title": "Calls is not currently enabled", "mobile.calls_see_logs": "See server logs", @@ -531,6 +537,7 @@ "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_view_profile": "View profile", "mobile.calls_viewing_screen": "You are viewing {name}'s screen", "mobile.calls_you": "(you)", "mobile.calls_you_2": "You",