diff --git a/app/actions/websocket/index.ts b/app/actions/websocket/index.ts index 27e648e91..1739c451e 100644 --- a/app/actions/websocket/index.ts +++ b/app/actions/websocket/index.ts @@ -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); diff --git a/app/components/post_draft/send_handler/send_handler.tsx b/app/components/post_draft/send_handler/send_handler.tsx index 3cda650c6..97cf500f0 100644 --- a/app/components/post_draft/send_handler/send_handler.tsx +++ b/app/components/post_draft/send_handler/send_handler.tsx @@ -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; diff --git a/app/products/calls/actions/calls.test.ts b/app/products/calls/actions/calls.test.ts index 3e4bb17c7..492a55320 100644 --- a/app/products/calls/actions/calls.test.ts +++ b/app/products/calls/actions/calls.test.ts @@ -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); diff --git a/app/products/calls/actions/calls.ts b/app/products/calls/actions/calls.ts index 16aff9e98..da917560b 100644 --- a/app/products/calls/actions/calls.ts +++ b/app/products/calls/actions/calls.ts @@ -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; +}; diff --git a/app/products/calls/client/rest.ts b/app/products/calls/client/rest.ts index 70778ded3..401f4c4e9 100644 --- a/app/products/calls/client/rest.ts +++ b/app/products/calls/client/rest.ts @@ -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; getCalls: () => Promise; getCallsConfig: () => Promise; enableChannelCalls: (channelId: string, enable: boolean) => Promise; + endCall: (channelId: string) => Promise; } 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; diff --git a/app/products/calls/components/join_call_banner/index.ts b/app/products/calls/components/join_call_banner/index.ts index f4a731cde..ea1d60b8f 100644 --- a/app/products/calls/components/join_call_banner/index.ts +++ b/app/products/calls/components/join_call_banner/index.ts @@ -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$([]))), diff --git a/app/products/calls/components/leave_and_join_alert.tsx b/app/products/calls/components/leave_and_join_alert.tsx index 695350711..65a74b9ad 100644 --- a/app/products/calls/components/leave_and_join_alert.tsx +++ b/app/products/calls/components/leave_and_join_alert.tsx @@ -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, diff --git a/app/products/calls/connection/connection.ts b/app/products/calls/connection/connection.ts index f91e511db..dcef6e454 100644 --- a/app/products/calls/connection/connection.ts +++ b/app/products/calls/connection/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 {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; diff --git a/app/products/calls/connection/websocket_event_handlers.ts b/app/products/calls/connection/websocket_event_handlers.ts index 3cd6f6948..48d5bc0b1 100644 --- a/app/products/calls/connection/websocket_event_handlers.ts +++ b/app/products/calls/connection/websocket_event_handlers.ts @@ -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, }); }; diff --git a/app/products/calls/screens/call_screen/call_screen.tsx b/app/products/calls/screens/call_screen/call_screen.tsx index 4fa93a09e..ac543bc15 100644 --- a/app/products/calls/screens/call_screen/call_screen.tsx +++ b/app/products/calls/screens/call_screen/call_screen.tsx @@ -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; diff --git a/app/products/calls/state/actions.test.ts b/app/products/calls/state/actions.test.ts index e5e5b4caf..1a3f7bbb5 100644 --- a/app/products/calls/state/actions.test.ts +++ b/app/products/calls/state/actions.test.ts @@ -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 = { diff --git a/app/products/calls/state/actions.ts b/app/products/calls/state/actions.ts index 84c05bb37..35a16a702 100644 --- a/app/products/calls/state/actions.ts +++ b/app/products/calls/state/actions.ts @@ -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) => { diff --git a/app/products/calls/types/calls.ts b/app/products/calls/types/calls.ts index 958a91d9f..facd5fce0 100644 --- a/app/products/calls/types/calls.ts +++ b/app/products/calls/types/calls.ts @@ -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; +} diff --git a/app/products/calls/utils.ts b/app/products/calls/utils.ts index bceb22337..9a5e4a5de 100644 --- a/app/products/calls/utils.ts +++ b/app/products/calls/utils.ts @@ -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, presenterID?: string): CallParticipant[] { if (!participants) { return []; diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 8c40c9c20..6610349a4 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -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",