| /* |
| * Copyright (C) 2022 Savoir-faire Linux Inc. |
| * |
| * This program is free software; you can redistribute it and/or modify |
| * it under the terms of the GNU Affero General Public License as |
| * published by the Free Software Foundation; either version 3 of the |
| * License, or (at your option) any later version. |
| * |
| * This program is distributed in the hope that it will be useful, |
| * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| * GNU Affero General Public License for more details. |
| * |
| * You should have received a copy of the GNU Affero General Public |
| * License along with this program. If not, see |
| * <https://www.gnu.org/licenses/>. |
| */ |
| import { ConversationInfos, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common'; |
| import { useCallback, useContext, useEffect, useMemo, useState } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| |
| import { AlertSnackbarContext } from '../contexts/AlertSnackbarProvider'; |
| import { useAuthContext } from '../contexts/AuthProvider'; |
| import { useUserMediaContext } from '../contexts/UserMediaProvider'; |
| import { useWebSocketContext } from '../contexts/WebSocketProvider'; |
| import { ConversationMember } from '../models/conversation-member'; |
| import { useConversationInfosQuery, useMembersQuery } from '../services/conversationQueries'; |
| import { callTimeoutMs } from '../utils/constants'; |
| import { AsyncSetState, SetState } from '../utils/utils'; |
| import { useWebRtcManager } from '../webrtc/WebRtcManager'; |
| |
| export type CallRole = 'caller' | 'receiver' | undefined; |
| |
| export type CallData = { |
| conversationId: string; |
| role: CallRole; |
| withVideoOn?: boolean; |
| }; |
| |
| export enum CallStatus { |
| Default, |
| Loading, |
| Ringing, |
| Connecting, |
| InCall, |
| PermissionsDenied, |
| } |
| |
| export enum VideoStatus { |
| Off, |
| Camera, |
| ScreenShare, |
| } |
| |
| export interface ICallManager { |
| callData: CallData | undefined; |
| callConversationInfos: ConversationInfos | undefined; |
| callMembers: ConversationMember[] | undefined; |
| |
| remoteStreams: readonly MediaStream[]; |
| |
| isAudioOn: boolean; |
| setIsAudioOn: SetState<boolean>; |
| videoStatus: VideoStatus; |
| updateVideoStatus: AsyncSetState<VideoStatus>; |
| isChatShown: boolean; |
| setIsChatShown: SetState<boolean>; |
| isFullscreen: boolean; |
| setIsFullscreen: SetState<boolean>; |
| callStatus: CallStatus; |
| callStartTime: number | undefined; |
| |
| startCall: (conversationId: string, withVideoOn?: boolean) => void; |
| acceptCall: (withVideoOn: boolean) => void; |
| endCall: () => void; |
| } |
| |
| export const useCallManager = () => { |
| const { setAlertContent } = useContext(AlertSnackbarContext); |
| const [callData, setCallData] = useState<CallData>(); |
| const webSocket = useWebSocketContext(); |
| const navigate = useNavigate(); |
| const { data: callConversationInfos } = useConversationInfosQuery(callData?.conversationId); |
| const { data: callMembers } = useMembersQuery(callData?.conversationId); |
| |
| const { |
| localStream, |
| updateLocalStream, |
| screenShareLocalStream, |
| updateScreenShare, |
| setAudioInputDeviceId, |
| setVideoDeviceId, |
| stopMedias, |
| } = useUserMediaContext(); |
| const { account } = useAuthContext(); |
| const webRtcManager = useWebRtcManager(); |
| |
| const [isAudioOn, setIsAudioOn] = useState(false); |
| const [videoStatus, setVideoStatus] = useState(VideoStatus.Off); |
| const [isChatShown, setIsChatShown] = useState(false); |
| const [isFullscreen, setIsFullscreen] = useState(false); |
| const [callStatus, setCallStatus] = useState(CallStatus.Default); |
| const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined); |
| |
| useEffect(() => { |
| const callInviteListener = ({ conversationId, withVideoOn }: WebSocketMessageTable['onCallInvite']) => { |
| if (callData) { |
| // TODO: Currently, we display a notification if already in a call. |
| // In the future, we should handle receiving a call while already in another. |
| setAlertContent({ |
| messageI18nKey: 'missed_incoming_call', |
| messageI18nContext: { conversationId }, |
| severity: 'info', |
| alertOpen: true, |
| }); |
| return; |
| } |
| |
| setCallData({ conversationId: conversationId, role: 'receiver', withVideoOn }); |
| navigate(`/conversation/${conversationId}`); |
| }; |
| |
| webSocket.bind(WebSocketMessageType.onCallInvite, callInviteListener); |
| |
| return () => { |
| webSocket.unbind(WebSocketMessageType.onCallInvite, callInviteListener); |
| }; |
| }, [webSocket, navigate, callData, setAlertContent]); |
| |
| const conversationId = callData?.conversationId; |
| const contactUri = callMembers?.[0]?.contact.uri; |
| const connectionInfos = contactUri ? webRtcManager.connectionsInfos[contactUri] : undefined; |
| const remoteStreams = useMemo(() => connectionInfos?.remoteStreams || [], [connectionInfos]); |
| const iceConnectionState = connectionInfos?.iceConnectionState; |
| |
| // TODO: Transform the effect into a callback |
| const updateLocalStreams = webRtcManager.updateLocalStreams; |
| useEffect(() => { |
| if ((!localStream && !screenShareLocalStream) || !updateLocalStreams) { |
| return; |
| } |
| |
| updateLocalStreams(localStream, screenShareLocalStream); |
| }, [localStream, screenShareLocalStream, updateLocalStreams]); |
| |
| const closeConnection = useCallback(() => { |
| stopMedias(); |
| webRtcManager.clean(); |
| }, [stopMedias, webRtcManager]); |
| |
| // Tracks logic should be moved into UserMediaProvider |
| useEffect(() => { |
| if (localStream) { |
| for (const track of localStream.getAudioTracks()) { |
| track.enabled = isAudioOn; |
| const deviceId = track.getSettings().deviceId; |
| if (deviceId) { |
| setAudioInputDeviceId(deviceId); |
| } |
| } |
| } |
| }, [isAudioOn, localStream, setAudioInputDeviceId]); |
| |
| // Tracks logic should be moved into UserMediaProvider |
| useEffect(() => { |
| if (localStream) { |
| for (const track of localStream.getVideoTracks()) { |
| track.enabled = videoStatus === VideoStatus.Camera; |
| const deviceId = track.getSettings().deviceId; |
| if (deviceId) { |
| setVideoDeviceId(deviceId); |
| } |
| } |
| } |
| }, [videoStatus, localStream, setVideoDeviceId]); |
| |
| // Track logic should be moved into UserMediaProvider |
| const updateVideoStatus = useCallback( |
| async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => { |
| if (typeof newStatus === 'function') { |
| newStatus = newStatus(videoStatus); |
| } |
| |
| const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare); |
| if (stream) { |
| for (const track of stream.getTracks()) { |
| track.addEventListener('ended', () => { |
| console.warn('Browser ended screen sharing'); |
| updateVideoStatus(VideoStatus.Off); |
| }); |
| } |
| } |
| |
| setVideoStatus(newStatus); |
| }, |
| [videoStatus, updateScreenShare] |
| ); |
| |
| useEffect(() => { |
| const onFullscreenChange = () => { |
| setIsFullscreen(document.fullscreenElement !== null); |
| }; |
| |
| document.addEventListener('fullscreenchange', onFullscreenChange); |
| return () => { |
| document.removeEventListener('fullscreenchange', onFullscreenChange); |
| }; |
| }, []); |
| |
| const startCall = useCallback( |
| (conversationId: string, withVideoOn = false) => { |
| setCallData({ conversationId, withVideoOn, role: 'caller' }); |
| if (callStatus === CallStatus.Default) { |
| setCallStatus(CallStatus.Loading); |
| updateLocalStream() |
| .then(() => { |
| const callInvite: WebSocketMessageTable['sendCallInvite'] = { |
| conversationId: conversationId, |
| withVideoOn, |
| }; |
| |
| setCallStatus(CallStatus.Ringing); |
| setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off); |
| webSocket.send(WebSocketMessageType.sendCallInvite, callInvite); |
| }) |
| .catch((e) => { |
| console.error(e); |
| setCallStatus(CallStatus.PermissionsDenied); |
| }); |
| } |
| }, |
| [webSocket, updateLocalStream, callStatus] |
| ); |
| |
| const acceptCall = useCallback( |
| (withVideoOn: boolean) => { |
| if (!callMembers || !conversationId) { |
| console.warn('acceptCall without callMembers or conversationId'); |
| return; |
| } |
| setCallStatus(CallStatus.Loading); |
| updateLocalStream() |
| .then(() => { |
| const callAccept: WebSocketMessageTable['sendCallJoin'] = { |
| conversationId, |
| }; |
| |
| setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off); |
| setCallStatus(CallStatus.Connecting); |
| console.info('Sending CallJoin', callAccept); |
| webSocket.send(WebSocketMessageType.sendCallJoin, callAccept); |
| // TODO: move this to "onWebRtcOffer" listener so we don't add connections for non-connected members |
| callMembers.forEach((member) => |
| webRtcManager.addConnection( |
| webSocket, |
| account, |
| member.contact.uri, |
| callData, |
| localStream, |
| screenShareLocalStream |
| ) |
| ); |
| }) |
| .catch((e) => { |
| console.error(e); |
| setCallStatus(CallStatus.PermissionsDenied); |
| }); |
| }, |
| [ |
| account, |
| callData, |
| conversationId, |
| localStream, |
| callMembers, |
| screenShareLocalStream, |
| updateLocalStream, |
| webRtcManager, |
| webSocket, |
| ] |
| ); |
| |
| useEffect(() => { |
| if (callData?.role === 'caller' && callStatus === CallStatus.Ringing) { |
| const callJoinListener = (data: WebSocketMessageTable['onCallJoin']) => { |
| console.info('Received event on CallJoin', data, callData); |
| if (data.conversationId !== conversationId) { |
| console.warn('Wrong incoming conversationId, ignoring action'); |
| return; |
| } |
| |
| setCallStatus(CallStatus.Connecting); |
| |
| webRtcManager.addConnection(webSocket, account, data.senderId, callData, localStream, screenShareLocalStream); |
| }; |
| |
| webSocket.bind(WebSocketMessageType.onCallJoin, callJoinListener); |
| |
| return () => { |
| webSocket.unbind(WebSocketMessageType.onCallJoin, callJoinListener); |
| }; |
| } |
| }, [account, callData, callStatus, conversationId, localStream, screenShareLocalStream, webRtcManager, webSocket]); |
| |
| const endCall = useCallback(() => { |
| if (!conversationId) { |
| return; |
| } |
| |
| const callExit: WebSocketMessageTable['sendCallExit'] = { |
| conversationId, |
| }; |
| |
| console.info('Sending CallExit', callExit); |
| closeConnection(); |
| webSocket.send(WebSocketMessageType.sendCallExit, callExit); |
| setCallData(undefined); |
| setCallStatus(CallStatus.Default); |
| // TODO: write in chat that the call ended |
| }, [webSocket, conversationId, closeConnection]); |
| |
| useEffect(() => { |
| const callExitListener = (data: WebSocketMessageTable['onCallExit']) => { |
| console.info('Received event on CallExit', data); |
| if (data.conversationId !== conversationId) { |
| console.warn('Wrong incoming conversationId, ignoring action'); |
| return; |
| } |
| |
| endCall(); |
| // TODO: write in chat that the call ended |
| }; |
| |
| webSocket.bind(WebSocketMessageType.onCallExit, callExitListener); |
| return () => { |
| webSocket.unbind(WebSocketMessageType.onCallExit, callExitListener); |
| }; |
| }, [webSocket, endCall, conversationId]); |
| |
| useEffect(() => { |
| if ( |
| callStatus === CallStatus.Connecting && |
| (iceConnectionState === 'connected' || iceConnectionState === 'completed') |
| ) { |
| console.info('Changing call status to InCall'); |
| setCallStatus(CallStatus.InCall); |
| setCallStartTime(Date.now()); |
| } |
| }, [iceConnectionState, callStatus]); |
| |
| useEffect(() => { |
| if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') { |
| console.info('ICE connection disconnected or failed, ending call'); |
| endCall(); |
| } |
| }, [iceConnectionState, callStatus, videoStatus, endCall]); |
| |
| useEffect(() => { |
| const checkStatusTimeout = () => { |
| if (callStatus !== CallStatus.InCall) { |
| endCall(); |
| } |
| }; |
| const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs); |
| |
| return () => { |
| clearTimeout(timeoutId); |
| }; |
| }, [callStatus, endCall]); |
| |
| return useMemo( |
| () => ({ |
| callData, |
| callConversationInfos, |
| callMembers, |
| remoteStreams, |
| isAudioOn, |
| setIsAudioOn, |
| videoStatus, |
| updateVideoStatus, |
| isChatShown, |
| setIsChatShown, |
| isFullscreen, |
| setIsFullscreen, |
| callStatus, |
| callStartTime, |
| startCall, |
| acceptCall, |
| endCall, |
| }), |
| [ |
| callData, |
| callConversationInfos, |
| callMembers, |
| remoteStreams, |
| isAudioOn, |
| videoStatus, |
| setIsAudioOn, |
| updateVideoStatus, |
| isChatShown, |
| setIsChatShown, |
| isFullscreen, |
| setIsFullscreen, |
| callStatus, |
| callStartTime, |
| startCall, |
| acceptCall, |
| endCall, |
| ] |
| ); |
| }; |