blob: b0837b0e2723f217a97300a6f99e2c821f87ee07 [file] [log] [blame]
simonf929a362022-11-18 16:53:45 -05001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
idillon255682e2023-02-06 13:25:26 -050018import { ConversationInfos, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
simon5c677962022-12-02 16:51:54 -050019import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
idillon255682e2023-02-06 13:25:26 -050020import { useNavigate } from 'react-router-dom';
simonf929a362022-11-18 16:53:45 -050021
idillonc45a43d2023-02-10 18:12:10 -050022import { AlertSnackbarContext } from '../contexts/AlertSnackbarProvider';
23import { useAuthContext } from '../contexts/AuthProvider';
24import { useUserMediaContext } from '../contexts/UserMediaProvider';
25import { useWebSocketContext } from '../contexts/WebSocketProvider';
idillon07d31cc2022-12-06 22:40:14 -050026import { ConversationMember } from '../models/conversation-member';
idillon255682e2023-02-06 13:25:26 -050027import { useConversationInfosQuery, useMembersQuery } from '../services/conversationQueries';
MichelleSS55164202022-11-25 18:36:14 -050028import { callTimeoutMs } from '../utils/constants';
idillon255682e2023-02-06 13:25:26 -050029import { AsyncSetState, SetState } from '../utils/utils';
idilloncf42c652023-01-31 18:56:17 -050030import { useWebRtcManager } from '../webrtc/WebRtcManager';
simonf929a362022-11-18 16:53:45 -050031
idillon255682e2023-02-06 13:25:26 -050032export type CallRole = 'caller' | 'receiver' | undefined;
33
34export type CallData = {
35 conversationId: string;
36 role: CallRole;
37 withVideoOn?: boolean;
38};
simonf929a362022-11-18 16:53:45 -050039
40export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050041 Default,
simon9076a9a2022-11-29 17:13:01 -050042 Loading,
simonf929a362022-11-18 16:53:45 -050043 Ringing,
44 Connecting,
45 InCall,
simon9076a9a2022-11-29 17:13:01 -050046 PermissionsDenied,
simonf929a362022-11-18 16:53:45 -050047}
48
simon1e2bf342022-12-02 12:19:40 -050049export enum VideoStatus {
50 Off,
51 Camera,
52 ScreenShare,
53}
54
idillon255682e2023-02-06 13:25:26 -050055export interface ICallManager {
56 callData: CallData | undefined;
57 callConversationInfos: ConversationInfos | undefined;
58 callMembers: ConversationMember[] | undefined;
59
idilloncf42c652023-01-31 18:56:17 -050060 remoteStreams: readonly MediaStream[];
61
simonf929a362022-11-18 16:53:45 -050062 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050063 setIsAudioOn: SetState<boolean>;
simon1e2bf342022-12-02 12:19:40 -050064 videoStatus: VideoStatus;
65 updateVideoStatus: AsyncSetState<VideoStatus>;
simonf9d78f22022-11-25 15:47:15 -050066 isChatShown: boolean;
67 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050068 isFullscreen: boolean;
69 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050070 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050071 callStartTime: number | undefined;
simonf929a362022-11-18 16:53:45 -050072
idillon255682e2023-02-06 13:25:26 -050073 startCall: (conversationId: string, withVideoOn?: boolean) => void;
MichelleSS55164202022-11-25 18:36:14 -050074 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050075 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050076}
77
idillon255682e2023-02-06 13:25:26 -050078export const useCallManager = () => {
79 const { setAlertContent } = useContext(AlertSnackbarContext);
80 const [callData, setCallData] = useState<CallData>();
idillona4b96ab2023-02-01 15:30:12 -050081 const webSocket = useWebSocketContext();
idillon255682e2023-02-06 13:25:26 -050082 const navigate = useNavigate();
83 const { data: callConversationInfos } = useConversationInfosQuery(callData?.conversationId);
84 const { data: callMembers } = useMembersQuery(callData?.conversationId);
simonf353ef42022-11-28 23:14:53 -050085
idillon27dab022023-02-02 17:55:47 -050086 const {
87 localStream,
88 updateLocalStream,
89 screenShareLocalStream,
90 updateScreenShare,
91 setAudioInputDeviceId,
92 setVideoDeviceId,
93 stopMedias,
94 } = useUserMediaContext();
idilloncf42c652023-01-31 18:56:17 -050095 const { account } = useAuthContext();
96 const webRtcManager = useWebRtcManager();
97
simonf929a362022-11-18 16:53:45 -050098 const [isAudioOn, setIsAudioOn] = useState(false);
simon1e2bf342022-12-02 12:19:40 -050099 const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
simonf9d78f22022-11-25 15:47:15 -0500100 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500101 const [isFullscreen, setIsFullscreen] = useState(false);
simone35acc22022-12-02 16:51:12 -0500102 const [callStatus, setCallStatus] = useState(CallStatus.Default);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500103 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500104
idilloncf42c652023-01-31 18:56:17 -0500105 useEffect(() => {
idillon255682e2023-02-06 13:25:26 -0500106 const callInviteListener = ({ conversationId, withVideoOn }: WebSocketMessageTable['onCallInvite']) => {
107 if (callData) {
108 // TODO: Currently, we display a notification if already in a call.
109 // In the future, we should handle receiving a call while already in another.
110 setAlertContent({
111 messageI18nKey: 'missed_incoming_call',
112 messageI18nContext: { conversationId },
113 severity: 'info',
114 alertOpen: true,
115 });
116 return;
117 }
118
119 setCallData({ conversationId: conversationId, role: 'receiver', withVideoOn });
120 navigate(`/conversation/${conversationId}`);
121 };
122
123 webSocket.bind(WebSocketMessageType.onCallInvite, callInviteListener);
124
125 return () => {
126 webSocket.unbind(WebSocketMessageType.onCallInvite, callInviteListener);
127 };
128 }, [webSocket, navigate, callData, setAlertContent]);
129
130 const conversationId = callData?.conversationId;
131 const contactUri = callMembers?.[0]?.contact.uri;
132 const connectionInfos = contactUri ? webRtcManager.connectionsInfos[contactUri] : undefined;
133 const remoteStreams = useMemo(() => connectionInfos?.remoteStreams || [], [connectionInfos]);
134 const iceConnectionState = connectionInfos?.iceConnectionState;
idilloncf42c652023-01-31 18:56:17 -0500135
idilloncf42c652023-01-31 18:56:17 -0500136 // TODO: Transform the effect into a callback
137 const updateLocalStreams = webRtcManager.updateLocalStreams;
138 useEffect(() => {
139 if ((!localStream && !screenShareLocalStream) || !updateLocalStreams) {
140 return;
141 }
142
143 updateLocalStreams(localStream, screenShareLocalStream);
144 }, [localStream, screenShareLocalStream, updateLocalStreams]);
145
idilloncf42c652023-01-31 18:56:17 -0500146 const closeConnection = useCallback(() => {
idillon27dab022023-02-02 17:55:47 -0500147 stopMedias();
idilloncf42c652023-01-31 18:56:17 -0500148 webRtcManager.clean();
idillon27dab022023-02-02 17:55:47 -0500149 }, [stopMedias, webRtcManager]);
simonf929a362022-11-18 16:53:45 -0500150
idillon27dab022023-02-02 17:55:47 -0500151 // Tracks logic should be moved into UserMediaProvider
simon492e8402022-11-29 16:48:37 -0500152 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500153 if (localStream) {
154 for (const track of localStream.getAudioTracks()) {
155 track.enabled = isAudioOn;
simon492e8402022-11-29 16:48:37 -0500156 const deviceId = track.getSettings().deviceId;
157 if (deviceId) {
158 setAudioInputDeviceId(deviceId);
159 }
simon9076a9a2022-11-29 17:13:01 -0500160 }
simonfeaa1db2022-11-26 20:13:18 -0500161 }
idillon27dab022023-02-02 17:55:47 -0500162 }, [isAudioOn, localStream, setAudioInputDeviceId]);
simonf929a362022-11-18 16:53:45 -0500163
idillon27dab022023-02-02 17:55:47 -0500164 // Tracks logic should be moved into UserMediaProvider
simonf929a362022-11-18 16:53:45 -0500165 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500166 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500167 for (const track of localStream.getVideoTracks()) {
simon1e2bf342022-12-02 12:19:40 -0500168 track.enabled = videoStatus === VideoStatus.Camera;
simon492e8402022-11-29 16:48:37 -0500169 const deviceId = track.getSettings().deviceId;
170 if (deviceId) {
171 setVideoDeviceId(deviceId);
172 }
MichelleSS55164202022-11-25 18:36:14 -0500173 }
simonff1cb352022-11-24 15:15:26 -0500174 }
idillon27dab022023-02-02 17:55:47 -0500175 }, [videoStatus, localStream, setVideoDeviceId]);
simon1e2bf342022-12-02 12:19:40 -0500176
idillon27dab022023-02-02 17:55:47 -0500177 // Track logic should be moved into UserMediaProvider
simon1e2bf342022-12-02 12:19:40 -0500178 const updateVideoStatus = useCallback(
179 async (newStatus: ((prevState: VideoStatus) => VideoStatus) | VideoStatus) => {
180 if (typeof newStatus === 'function') {
181 newStatus = newStatus(videoStatus);
182 }
183
184 const stream = await updateScreenShare(newStatus === VideoStatus.ScreenShare);
185 if (stream) {
186 for (const track of stream.getTracks()) {
187 track.addEventListener('ended', () => {
188 console.warn('Browser ended screen sharing');
189 updateVideoStatus(VideoStatus.Off);
190 });
191 }
192 }
193
194 setVideoStatus(newStatus);
195 },
196 [videoStatus, updateScreenShare]
197 );
simonf929a362022-11-18 16:53:45 -0500198
199 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500200 const onFullscreenChange = () => {
201 setIsFullscreen(document.fullscreenElement !== null);
202 };
203
204 document.addEventListener('fullscreenchange', onFullscreenChange);
205 return () => {
206 document.removeEventListener('fullscreenchange', onFullscreenChange);
207 };
208 }, []);
209
idillon255682e2023-02-06 13:25:26 -0500210 const startCall = useCallback(
211 (conversationId: string, withVideoOn = false) => {
212 setCallData({ conversationId, withVideoOn, role: 'caller' });
213 if (callStatus === CallStatus.Default) {
214 setCallStatus(CallStatus.Loading);
215 updateLocalStream()
216 .then(() => {
217 const callInvite: WebSocketMessageTable['sendCallInvite'] = {
218 conversationId: conversationId,
219 withVideoOn,
220 };
simon9076a9a2022-11-29 17:13:01 -0500221
idillon255682e2023-02-06 13:25:26 -0500222 setCallStatus(CallStatus.Ringing);
223 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
224 webSocket.send(WebSocketMessageType.sendCallInvite, callInvite);
225 })
226 .catch((e) => {
227 console.error(e);
228 setCallStatus(CallStatus.PermissionsDenied);
229 });
230 }
231 },
232 [webSocket, updateLocalStream, callStatus]
233 );
simon9076a9a2022-11-29 17:13:01 -0500234
235 const acceptCall = useCallback(
236 (withVideoOn: boolean) => {
idillon255682e2023-02-06 13:25:26 -0500237 if (!callMembers || !conversationId) {
238 console.warn('acceptCall without callMembers or conversationId');
239 return;
240 }
simon9076a9a2022-11-29 17:13:01 -0500241 setCallStatus(CallStatus.Loading);
simon492e8402022-11-29 16:48:37 -0500242 updateLocalStream()
simon9076a9a2022-11-29 17:13:01 -0500243 .then(() => {
idillon255682e2023-02-06 13:25:26 -0500244 const callAccept: WebSocketMessageTable['sendCallJoin'] = {
simon9076a9a2022-11-29 17:13:01 -0500245 conversationId,
246 };
247
simon1e2bf342022-12-02 12:19:40 -0500248 setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
simon9076a9a2022-11-29 17:13:01 -0500249 setCallStatus(CallStatus.Connecting);
idillon255682e2023-02-06 13:25:26 -0500250 console.info('Sending CallJoin', callAccept);
251 webSocket.send(WebSocketMessageType.sendCallJoin, callAccept);
252 // TODO: move this to "onWebRtcOffer" listener so we don't add connections for non-connected members
253 callMembers.forEach((member) =>
254 webRtcManager.addConnection(
255 webSocket,
256 account,
257 member.contact.uri,
258 callData,
259 localStream,
260 screenShareLocalStream
261 )
262 );
simon9076a9a2022-11-29 17:13:01 -0500263 })
264 .catch((e) => {
265 console.error(e);
266 setCallStatus(CallStatus.PermissionsDenied);
267 });
268 },
idillon255682e2023-02-06 13:25:26 -0500269 [
270 account,
271 callData,
272 conversationId,
273 localStream,
274 callMembers,
275 screenShareLocalStream,
276 updateLocalStream,
277 webRtcManager,
278 webSocket,
279 ]
simon9076a9a2022-11-29 17:13:01 -0500280 );
281
282 useEffect(() => {
idillon255682e2023-02-06 13:25:26 -0500283 if (callData?.role === 'caller' && callStatus === CallStatus.Ringing) {
284 const callJoinListener = (data: WebSocketMessageTable['onCallJoin']) => {
285 console.info('Received event on CallJoin', data, callData);
Charliec18d6402022-11-27 13:01:04 -0500286 if (data.conversationId !== conversationId) {
287 console.warn('Wrong incoming conversationId, ignoring action');
288 return;
289 }
290
simonf929a362022-11-18 16:53:45 -0500291 setCallStatus(CallStatus.Connecting);
292
idillon255682e2023-02-06 13:25:26 -0500293 webRtcManager.addConnection(webSocket, account, data.senderId, callData, localStream, screenShareLocalStream);
simonf929a362022-11-18 16:53:45 -0500294 };
295
idillon255682e2023-02-06 13:25:26 -0500296 webSocket.bind(WebSocketMessageType.onCallJoin, callJoinListener);
simonf929a362022-11-18 16:53:45 -0500297
298 return () => {
idillon255682e2023-02-06 13:25:26 -0500299 webSocket.unbind(WebSocketMessageType.onCallJoin, callJoinListener);
simonf929a362022-11-18 16:53:45 -0500300 };
301 }
idillon255682e2023-02-06 13:25:26 -0500302 }, [account, callData, callStatus, conversationId, localStream, screenShareLocalStream, webRtcManager, webSocket]);
simonf929a362022-11-18 16:53:45 -0500303
simon9076a9a2022-11-29 17:13:01 -0500304 const endCall = useCallback(() => {
idillon255682e2023-02-06 13:25:26 -0500305 if (!conversationId) {
306 return;
307 }
308
309 const callExit: WebSocketMessageTable['sendCallExit'] = {
simon9076a9a2022-11-29 17:13:01 -0500310 conversationId,
311 };
MichelleSS55164202022-11-25 18:36:14 -0500312
idillon255682e2023-02-06 13:25:26 -0500313 console.info('Sending CallExit', callExit);
simon9076a9a2022-11-29 17:13:01 -0500314 closeConnection();
idillon255682e2023-02-06 13:25:26 -0500315 webSocket.send(WebSocketMessageType.sendCallExit, callExit);
316 setCallData(undefined);
317 setCallStatus(CallStatus.Default);
simon9076a9a2022-11-29 17:13:01 -0500318 // TODO: write in chat that the call ended
idillon255682e2023-02-06 13:25:26 -0500319 }, [webSocket, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500320
321 useEffect(() => {
idillon255682e2023-02-06 13:25:26 -0500322 const callExitListener = (data: WebSocketMessageTable['onCallExit']) => {
323 console.info('Received event on CallExit', data);
Charliec18d6402022-11-27 13:01:04 -0500324 if (data.conversationId !== conversationId) {
325 console.warn('Wrong incoming conversationId, ignoring action');
326 return;
327 }
328
idillon255682e2023-02-06 13:25:26 -0500329 endCall();
simonaccd8022022-11-24 15:04:53 -0500330 // TODO: write in chat that the call ended
331 };
332
idillon255682e2023-02-06 13:25:26 -0500333 webSocket.bind(WebSocketMessageType.onCallExit, callExitListener);
simonaccd8022022-11-24 15:04:53 -0500334 return () => {
idillon255682e2023-02-06 13:25:26 -0500335 webSocket.unbind(WebSocketMessageType.onCallExit, callExitListener);
simonaccd8022022-11-24 15:04:53 -0500336 };
idillon255682e2023-02-06 13:25:26 -0500337 }, [webSocket, endCall, conversationId]);
simonaccd8022022-11-24 15:04:53 -0500338
simonf929a362022-11-18 16:53:45 -0500339 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500340 if (
341 callStatus === CallStatus.Connecting &&
342 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
343 ) {
simonf929a362022-11-18 16:53:45 -0500344 console.info('Changing call status to InCall');
345 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500346 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500347 }
simon9076a9a2022-11-29 17:13:01 -0500348 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500349
MichelleSS55164202022-11-25 18:36:14 -0500350 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500351 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
352 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500353 endCall();
354 }
simon1e2bf342022-12-02 12:19:40 -0500355 }, [iceConnectionState, callStatus, videoStatus, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500356
357 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500358 const checkStatusTimeout = () => {
359 if (callStatus !== CallStatus.InCall) {
360 endCall();
simonff1cb352022-11-24 15:15:26 -0500361 }
MichelleSS55164202022-11-25 18:36:14 -0500362 };
363 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500364
MichelleSS55164202022-11-25 18:36:14 -0500365 return () => {
366 clearTimeout(timeoutId);
367 };
368 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500369
simon5c677962022-12-02 16:51:54 -0500370 return useMemo(
371 () => ({
idillon255682e2023-02-06 13:25:26 -0500372 callData,
373 callConversationInfos,
374 callMembers,
idilloncf42c652023-01-31 18:56:17 -0500375 remoteStreams,
simon5c677962022-12-02 16:51:54 -0500376 isAudioOn,
377 setIsAudioOn,
378 videoStatus,
379 updateVideoStatus,
380 isChatShown,
381 setIsChatShown,
382 isFullscreen,
383 setIsFullscreen,
simon5c677962022-12-02 16:51:54 -0500384 callStatus,
385 callStartTime,
idillon255682e2023-02-06 13:25:26 -0500386 startCall,
simon5c677962022-12-02 16:51:54 -0500387 acceptCall,
388 endCall,
389 }),
390 [
idillon255682e2023-02-06 13:25:26 -0500391 callData,
392 callConversationInfos,
393 callMembers,
idilloncf42c652023-01-31 18:56:17 -0500394 remoteStreams,
simon5c677962022-12-02 16:51:54 -0500395 isAudioOn,
simon5c677962022-12-02 16:51:54 -0500396 videoStatus,
idillon27dab022023-02-02 17:55:47 -0500397 setIsAudioOn,
simon5c677962022-12-02 16:51:54 -0500398 updateVideoStatus,
399 isChatShown,
idillon27dab022023-02-02 17:55:47 -0500400 setIsChatShown,
simon5c677962022-12-02 16:51:54 -0500401 isFullscreen,
idillon27dab022023-02-02 17:55:47 -0500402 setIsFullscreen,
simon5c677962022-12-02 16:51:54 -0500403 callStatus,
404 callStartTime,
idillon255682e2023-02-06 13:25:26 -0500405 startCall,
simon5c677962022-12-02 16:51:54 -0500406 acceptCall,
407 endCall,
408 ]
simonf929a362022-11-18 16:53:45 -0500409 );
410};