blob: e99399d563e778e5f25f66ad04bcab317965473b [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 */
MichelleSS55164202022-11-25 18:36:14 -050018import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050019import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
simonaccd8022022-11-24 15:04:53 -050020import { Navigate, useNavigate } from 'react-router-dom';
simonf929a362022-11-18 16:53:45 -050021
simonf353ef42022-11-28 23:14:53 -050022import LoadingPage from '../components/Loading';
simonf929a362022-11-18 16:53:45 -050023import { useUrlParams } from '../hooks/useUrlParams';
24import { CallRouteParams } from '../router';
MichelleSS55164202022-11-25 18:36:14 -050025import { callTimeoutMs } from '../utils/constants';
simonf9d78f22022-11-25 15:47:15 -050026import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050027import { ConversationContext } from './ConversationProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050028import { WebRtcContext } from './WebRtcProvider';
simonf353ef42022-11-28 23:14:53 -050029import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
simonf929a362022-11-18 16:53:45 -050030
31export type CallRole = 'caller' | 'receiver';
32
33export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050034 Default,
simonf929a362022-11-18 16:53:45 -050035 Ringing,
36 Connecting,
37 InCall,
38}
39
40export interface ICallContext {
41 mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
42
43 localStream: MediaStream | undefined;
44 remoteStream: MediaStream | undefined; // TODO: should be an array of participants. find a way to map MediaStream id to contactid https://stackoverflow.com/a/68663155/6592293
45
46 isAudioOn: boolean;
47 setAudioStatus: (isOn: boolean) => void;
48 isVideoOn: boolean;
49 setVideoStatus: (isOn: boolean) => void;
simonf9d78f22022-11-25 15:47:15 -050050 isChatShown: boolean;
51 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050052 isFullscreen: boolean;
53 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050054 callRole: CallRole;
55 callStatus: CallStatus;
Gabriel Rochone382a302022-11-23 12:37:04 -050056 callStartTime: Date | undefined;
simonf929a362022-11-18 16:53:45 -050057
MichelleSS55164202022-11-25 18:36:14 -050058 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050059 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050060}
61
62const defaultCallContext: ICallContext = {
63 mediaDevices: {
64 audioinput: [],
65 audiooutput: [],
66 videoinput: [],
67 },
68
69 localStream: undefined,
70 remoteStream: undefined,
71
72 isAudioOn: false,
73 setAudioStatus: () => {},
74 isVideoOn: false,
75 setVideoStatus: () => {},
simonf9d78f22022-11-25 15:47:15 -050076 isChatShown: false,
77 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050078 isFullscreen: false,
79 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050080 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050081 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -050082 callStartTime: undefined,
simonf929a362022-11-18 16:53:45 -050083
MichelleSS55164202022-11-25 18:36:14 -050084 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -050085 endCall: () => {},
simonf929a362022-11-18 16:53:45 -050086};
87
88export const CallContext = createContext<ICallContext>(defaultCallContext);
89
90export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -050091 const webSocket = useContext(WebSocketContext);
92 const { webRtcConnection } = useContext(WebRtcContext);
93
94 if (!webSocket || !webRtcConnection) {
95 return <LoadingPage />;
96 }
97
98 return (
99 <CallProvider webSocket={webSocket} webRtcConnection={webRtcConnection}>
100 {children}
101 </CallProvider>
102 );
103};
104
105const CallProvider = ({
106 children,
107 webSocket,
108 webRtcConnection,
109}: WithChildren & {
110 webSocket: IWebSocketContext;
111 webRtcConnection: RTCPeerConnection;
112}) => {
simonf929a362022-11-18 16:53:45 -0500113 const {
114 queryParams: { role: callRole },
simonff1cb352022-11-24 15:15:26 -0500115 state: routeState,
simonf929a362022-11-18 16:53:45 -0500116 } = useUrlParams<CallRouteParams>();
simonf353ef42022-11-28 23:14:53 -0500117 const { remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500118 const { conversationId, conversation } = useContext(ConversationContext);
simonaccd8022022-11-24 15:04:53 -0500119 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500120
121 const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
122 defaultCallContext.mediaDevices
123 );
124 const [localStream, setLocalStream] = useState<MediaStream>();
125
126 const [isAudioOn, setIsAudioOn] = useState(false);
127 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500128 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500129 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500130 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Gabriel Rochone382a302022-11-23 12:37:04 -0500131 const [callStartTime, setCallStartTime] = useState<Date | undefined>(undefined);
simonf929a362022-11-18 16:53:45 -0500132
simonff1cb352022-11-24 15:15:26 -0500133 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
134 // The client could make a single request with the conversationId, and the server would be tasked with sending
135 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500136 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
137
138 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500139 try {
simonfeaa1db2022-11-26 20:13:18 -0500140 // TODO: Wait until status is `InCall` before getting devices
141 navigator.mediaDevices.enumerateDevices().then((devices) => {
142 const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
143 audioinput: [],
144 audiooutput: [],
145 videoinput: [],
146 };
147
148 for (const device of devices) {
149 newMediaDevices[device.kind].push(device);
150 }
151
152 setMediaDevices(newMediaDevices);
153 });
154 } catch (e) {
155 console.error('Could not get media devices:', e);
156 }
157
158 try {
simonf929a362022-11-18 16:53:45 -0500159 navigator.mediaDevices
160 .getUserMedia({
161 audio: true, // TODO: Set both to false by default
162 video: true,
163 })
164 .then((stream) => {
165 for (const track of stream.getTracks()) {
166 // TODO: Set default from isVideoOn and isMicOn values
167 track.enabled = false;
168 }
169 setLocalStream(stream);
170 });
171 } catch (e) {
172 // TODO: Better handle user denial
173 console.error('Could not get media devices:', e);
174 }
simon8b4756f2022-11-27 17:15:50 -0500175 }, []);
simonf929a362022-11-18 16:53:45 -0500176
177 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500178 if (localStream) {
simonf929a362022-11-18 16:53:45 -0500179 for (const track of localStream.getTracks()) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500180 webRtcConnection.addTrack(track, localStream);
simonf929a362022-11-18 16:53:45 -0500181 }
182 }
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500183 }, [localStream, webRtcConnection]);
simonf929a362022-11-18 16:53:45 -0500184
MichelleSS55164202022-11-25 18:36:14 -0500185 const setAudioStatus = useCallback(
186 (isOn: boolean) => {
187 if (!localStream) {
188 return;
189 }
190
191 for (const track of localStream.getAudioTracks()) {
192 track.enabled = isOn;
193 }
194
195 setIsAudioOn(isOn);
196 },
197 [localStream]
198 );
199
200 const setVideoStatus = useCallback(
201 (isOn: boolean) => {
202 if (!localStream) {
203 return;
204 }
205
206 for (const track of localStream.getVideoTracks()) {
207 track.enabled = isOn;
208 }
209
210 setIsVideoOn(isOn);
211 },
212 [localStream]
213 );
214
simonff1cb352022-11-24 15:15:26 -0500215 useEffect(() => {
simonff1cb352022-11-24 15:15:26 -0500216 if (callRole === 'caller' && callStatus === CallStatus.Default) {
MichelleSS55164202022-11-25 18:36:14 -0500217 const callBegin: CallBegin = {
simonff1cb352022-11-24 15:15:26 -0500218 contactId: contactUri,
219 conversationId,
MichelleSS55164202022-11-25 18:36:14 -0500220 withVideoOn: routeState?.isVideoOn ?? false,
simonff1cb352022-11-24 15:15:26 -0500221 };
simonf929a362022-11-18 16:53:45 -0500222
simonff1cb352022-11-24 15:15:26 -0500223 console.info('Sending CallBegin', callBegin);
224 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
225 setCallStatus(CallStatus.Ringing);
MichelleSS55164202022-11-25 18:36:14 -0500226 setIsVideoOn(routeState?.isVideoOn ?? false);
simonff1cb352022-11-24 15:15:26 -0500227 }
MichelleSS55164202022-11-25 18:36:14 -0500228 }, [webSocket, callRole, callStatus, contactUri, conversationId, routeState]);
simonf929a362022-11-18 16:53:45 -0500229
230 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500231 const onFullscreenChange = () => {
232 setIsFullscreen(document.fullscreenElement !== null);
233 };
234
235 document.addEventListener('fullscreenchange', onFullscreenChange);
236 return () => {
237 document.removeEventListener('fullscreenchange', onFullscreenChange);
238 };
239 }, []);
240
241 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500242 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500243 const callAcceptListener = (data: CallAction) => {
244 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500245 if (data.conversationId !== conversationId) {
246 console.warn('Wrong incoming conversationId, ignoring action');
247 return;
248 }
249
simonf929a362022-11-18 16:53:45 -0500250 setCallStatus(CallStatus.Connecting);
251
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500252 webRtcConnection
simonf929a362022-11-18 16:53:45 -0500253 .createOffer({
254 offerToReceiveAudio: true,
255 offerToReceiveVideo: true,
256 })
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500257 .then((sdp) => {
258 sendWebRtcOffer(sdp);
simonf929a362022-11-18 16:53:45 -0500259 });
260 };
261
262 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
263
264 return () => {
265 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
266 };
267 }
Charliec18d6402022-11-27 13:01:04 -0500268 }, [callRole, webSocket, webRtcConnection, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500269
simonaccd8022022-11-24 15:04:53 -0500270 const quitCall = useCallback(() => {
MichelleSS55164202022-11-25 18:36:14 -0500271 const localTracks = localStream?.getTracks();
272 if (localTracks) {
273 for (const track of localTracks) {
274 track.stop();
275 }
276 }
277
simonaccd8022022-11-24 15:04:53 -0500278 webRtcConnection.close();
279 navigate(`/conversation/${conversationId}`);
MichelleSS55164202022-11-25 18:36:14 -0500280 }, [webRtcConnection, localStream, navigate, conversationId]);
simonaccd8022022-11-24 15:04:53 -0500281
282 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500283 const callEndListener = (data: CallAction) => {
284 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500285 if (data.conversationId !== conversationId) {
286 console.warn('Wrong incoming conversationId, ignoring action');
287 return;
288 }
289
simonaccd8022022-11-24 15:04:53 -0500290 quitCall();
291 // TODO: write in chat that the call ended
292 };
293
294 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
295 return () => {
296 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
297 };
298 }, [webSocket, navigate, conversationId, quitCall]);
299
simonf929a362022-11-18 16:53:45 -0500300 useEffect(() => {
301 if (callStatus === CallStatus.Connecting && isConnected) {
302 console.info('Changing call status to InCall');
303 setCallStatus(CallStatus.InCall);
MichelleSS55164202022-11-25 18:36:14 -0500304 setVideoStatus(isVideoOn);
Gabriel Rochone382a302022-11-23 12:37:04 -0500305 setCallStartTime(new Date());
simonf929a362022-11-18 16:53:45 -0500306 }
MichelleSS55164202022-11-25 18:36:14 -0500307 }, [isConnected, callStatus, setVideoStatus, isVideoOn]);
simonf929a362022-11-18 16:53:45 -0500308
MichelleSS55164202022-11-25 18:36:14 -0500309 const acceptCall = useCallback(
310 (withVideoOn: boolean) => {
MichelleSS55164202022-11-25 18:36:14 -0500311 const callAccept: CallAction = {
312 contactId: contactUri,
313 conversationId,
314 };
simonf929a362022-11-18 16:53:45 -0500315
MichelleSS55164202022-11-25 18:36:14 -0500316 console.info('Sending CallAccept', callAccept);
317 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
318 setIsVideoOn(withVideoOn);
319 setCallStatus(CallStatus.Connecting);
320 },
321 [webSocket, contactUri, conversationId]
322 );
simonf929a362022-11-18 16:53:45 -0500323
simonaccd8022022-11-24 15:04:53 -0500324 const endCall = useCallback(() => {
simonaccd8022022-11-24 15:04:53 -0500325 const callEnd: CallAction = {
326 contactId: contactUri,
327 conversationId,
328 };
329
330 console.info('Sending CallEnd', callEnd);
331 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
332 quitCall();
333 // TODO: write in chat that the call ended
334 }, [webSocket, contactUri, conversationId, quitCall]);
335
MichelleSS55164202022-11-25 18:36:14 -0500336 useEffect(() => {
337 const checkStatusTimeout = () => {
338 if (callStatus !== CallStatus.InCall) {
339 endCall();
simonff1cb352022-11-24 15:15:26 -0500340 }
MichelleSS55164202022-11-25 18:36:14 -0500341 };
342 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500343
MichelleSS55164202022-11-25 18:36:14 -0500344 return () => {
345 clearTimeout(timeoutId);
346 };
347 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500348
349 if (!callRole || callStatus === undefined) {
350 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500351 return <Navigate to={'/'} />;
352 }
353
354 return (
355 <CallContext.Provider
356 value={{
357 mediaDevices,
358 localStream,
359 remoteStream: remoteStreams?.at(-1),
360 isAudioOn,
361 setAudioStatus,
362 isVideoOn,
363 setVideoStatus,
simonf9d78f22022-11-25 15:47:15 -0500364 isChatShown,
365 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500366 isFullscreen,
367 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500368 callRole,
369 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500370 callStartTime,
simonf929a362022-11-18 16:53:45 -0500371 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500372 endCall,
simonf929a362022-11-18 16:53:45 -0500373 }}
374 >
375 {children}
376 </CallContext.Provider>
377 );
378};