blob: ce5769167fae12ac7cb94d2e96d9c7fe47927511 [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';
simon9076a9a2022-11-29 17:13:01 -050024import CallPermissionDenied from '../pages/CallPermissionDenied';
simonf929a362022-11-18 16:53:45 -050025import { CallRouteParams } from '../router';
MichelleSS55164202022-11-25 18:36:14 -050026import { callTimeoutMs } from '../utils/constants';
simonf9d78f22022-11-25 15:47:15 -050027import { SetState, WithChildren } from '../utils/utils';
simonf929a362022-11-18 16:53:45 -050028import { ConversationContext } from './ConversationProvider';
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050029import { WebRtcContext } from './WebRtcProvider';
simonf353ef42022-11-28 23:14:53 -050030import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
simonf929a362022-11-18 16:53:45 -050031
32export type CallRole = 'caller' | 'receiver';
33
34export enum CallStatus {
simonff1cb352022-11-24 15:15:26 -050035 Default,
simon9076a9a2022-11-29 17:13:01 -050036 Loading,
simonf929a362022-11-18 16:53:45 -050037 Ringing,
38 Connecting,
39 InCall,
simon9076a9a2022-11-29 17:13:01 -050040 PermissionsDenied,
simonf929a362022-11-18 16:53:45 -050041}
42
43export interface ICallContext {
simonf929a362022-11-18 16:53:45 -050044 isAudioOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050045 setIsAudioOn: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050046 isVideoOn: boolean;
simon9076a9a2022-11-29 17:13:01 -050047 setIsVideoOn: SetState<boolean>;
simonf9d78f22022-11-25 15:47:15 -050048 isChatShown: boolean;
49 setIsChatShown: SetState<boolean>;
simon2a5cf142022-11-25 15:47:35 -050050 isFullscreen: boolean;
51 setIsFullscreen: SetState<boolean>;
simonf929a362022-11-18 16:53:45 -050052 callRole: CallRole;
53 callStatus: CallStatus;
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -050054 callStartTime: number | undefined;
Charliec444ab72022-11-29 18:24:27 -050055 isAnswerButtonDisabled: boolean;
simonf929a362022-11-18 16:53:45 -050056
MichelleSS55164202022-11-25 18:36:14 -050057 acceptCall: (withVideoOn: boolean) => void;
simonaccd8022022-11-24 15:04:53 -050058 endCall: () => void;
simonf929a362022-11-18 16:53:45 -050059}
60
61const defaultCallContext: ICallContext = {
simonf929a362022-11-18 16:53:45 -050062 isAudioOn: false,
simon9076a9a2022-11-29 17:13:01 -050063 setIsAudioOn: () => {},
simonf929a362022-11-18 16:53:45 -050064 isVideoOn: false,
simon9076a9a2022-11-29 17:13:01 -050065 setIsVideoOn: () => {},
simonf9d78f22022-11-25 15:47:15 -050066 isChatShown: false,
67 setIsChatShown: () => {},
simon2a5cf142022-11-25 15:47:35 -050068 isFullscreen: false,
69 setIsFullscreen: () => {},
simonf929a362022-11-18 16:53:45 -050070 callRole: 'caller',
simonff1cb352022-11-24 15:15:26 -050071 callStatus: CallStatus.Default,
Gabriel Rochone382a302022-11-23 12:37:04 -050072 callStartTime: undefined,
Charliec444ab72022-11-29 18:24:27 -050073 isAnswerButtonDisabled: false,
simonf929a362022-11-18 16:53:45 -050074
MichelleSS55164202022-11-25 18:36:14 -050075 acceptCall: (_: boolean) => {},
simonaccd8022022-11-24 15:04:53 -050076 endCall: () => {},
simonf929a362022-11-18 16:53:45 -050077};
78
79export const CallContext = createContext<ICallContext>(defaultCallContext);
80
81export default ({ children }: WithChildren) => {
simonf353ef42022-11-28 23:14:53 -050082 const webSocket = useContext(WebSocketContext);
simonf353ef42022-11-28 23:14:53 -050083
simon9076a9a2022-11-29 17:13:01 -050084 if (!webSocket) {
simonf353ef42022-11-28 23:14:53 -050085 return <LoadingPage />;
86 }
87
simon9076a9a2022-11-29 17:13:01 -050088 return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
simonf353ef42022-11-28 23:14:53 -050089};
90
91const CallProvider = ({
92 children,
93 webSocket,
simonf353ef42022-11-28 23:14:53 -050094}: WithChildren & {
95 webSocket: IWebSocketContext;
simonf353ef42022-11-28 23:14:53 -050096}) => {
Charlieb837e8f2022-11-28 19:18:46 -050097 const { state: routeState } = useUrlParams<CallRouteParams>();
simon9076a9a2022-11-29 17:13:01 -050098 const { localStream, sendWebRtcOffer, iceConnectionState, closeConnection, getUserMedia } = useContext(WebRtcContext);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050099 const { conversationId, conversation } = useContext(ConversationContext);
simonaccd8022022-11-24 15:04:53 -0500100 const navigate = useNavigate();
simonf929a362022-11-18 16:53:45 -0500101
simonf929a362022-11-18 16:53:45 -0500102 const [isAudioOn, setIsAudioOn] = useState(false);
103 const [isVideoOn, setIsVideoOn] = useState(false);
simonf9d78f22022-11-25 15:47:15 -0500104 const [isChatShown, setIsChatShown] = useState(false);
simon2a5cf142022-11-25 15:47:35 -0500105 const [isFullscreen, setIsFullscreen] = useState(false);
simonff1cb352022-11-24 15:15:26 -0500106 const [callStatus, setCallStatus] = useState(routeState?.callStatus);
Charlieb837e8f2022-11-28 19:18:46 -0500107 const [callRole] = useState(routeState?.role);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500108 const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
Charliec444ab72022-11-29 18:24:27 -0500109 const [isAnswerButtonDisabled, setIsAnswerButtonDisabled] = useState(false);
simonf929a362022-11-18 16:53:45 -0500110
simonff1cb352022-11-24 15:15:26 -0500111 // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
112 // The client could make a single request with the conversationId, and the server would be tasked with sending
113 // all the individual requests to the members of the conversation.
simonf929a362022-11-18 16:53:45 -0500114 const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
115
116 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500117 if (localStream) {
118 for (const track of localStream.getAudioTracks()) {
119 track.enabled = isAudioOn;
120 }
simonfeaa1db2022-11-26 20:13:18 -0500121 }
simon9076a9a2022-11-29 17:13:01 -0500122 }, [isAudioOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500123
124 useEffect(() => {
simonf353ef42022-11-28 23:14:53 -0500125 if (localStream) {
MichelleSS55164202022-11-25 18:36:14 -0500126 for (const track of localStream.getVideoTracks()) {
simon9076a9a2022-11-29 17:13:01 -0500127 track.enabled = isVideoOn;
MichelleSS55164202022-11-25 18:36:14 -0500128 }
simonff1cb352022-11-24 15:15:26 -0500129 }
simon9076a9a2022-11-29 17:13:01 -0500130 }, [isVideoOn, localStream]);
simonf929a362022-11-18 16:53:45 -0500131
132 useEffect(() => {
simon2a5cf142022-11-25 15:47:35 -0500133 const onFullscreenChange = () => {
134 setIsFullscreen(document.fullscreenElement !== null);
135 };
136
137 document.addEventListener('fullscreenchange', onFullscreenChange);
138 return () => {
139 document.removeEventListener('fullscreenchange', onFullscreenChange);
140 };
141 }, []);
142
143 useEffect(() => {
simon9076a9a2022-11-29 17:13:01 -0500144 if (callRole === 'caller' && callStatus === CallStatus.Default) {
145 setCallStatus(CallStatus.Loading);
146 getUserMedia()
147 .then(() => {
148 const callBegin: CallBegin = {
149 contactId: contactUri,
150 conversationId,
151 withVideoOn: routeState?.isVideoOn ?? false,
152 };
153
154 setCallStatus(CallStatus.Ringing);
155 setIsVideoOn(routeState?.isVideoOn ?? false);
156 console.info('Sending CallBegin', callBegin);
157 webSocket.send(WebSocketMessageType.CallBegin, callBegin);
158 })
159 .catch((e) => {
160 console.error(e);
161 setCallStatus(CallStatus.PermissionsDenied);
162 });
163 }
164 }, [webSocket, getUserMedia, callRole, callStatus, contactUri, conversationId, routeState]);
165
166 const acceptCall = useCallback(
167 (withVideoOn: boolean) => {
168 setCallStatus(CallStatus.Loading);
Charliec444ab72022-11-29 18:24:27 -0500169 setIsAnswerButtonDisabled(true);
simon9076a9a2022-11-29 17:13:01 -0500170 getUserMedia()
171 .then(() => {
172 const callAccept: CallAction = {
173 contactId: contactUri,
174 conversationId,
175 };
176
177 setIsVideoOn(withVideoOn);
178 setCallStatus(CallStatus.Connecting);
179 console.info('Sending CallAccept', callAccept);
180 webSocket.send(WebSocketMessageType.CallAccept, callAccept);
181 })
182 .catch((e) => {
183 console.error(e);
184 setCallStatus(CallStatus.PermissionsDenied);
185 });
186 },
187 [webSocket, getUserMedia, contactUri, conversationId]
188 );
189
190 useEffect(() => {
simonf929a362022-11-18 16:53:45 -0500191 if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
simonaccd8022022-11-24 15:04:53 -0500192 const callAcceptListener = (data: CallAction) => {
193 console.info('Received event on CallAccept', data);
Charliec18d6402022-11-27 13:01:04 -0500194 if (data.conversationId !== conversationId) {
195 console.warn('Wrong incoming conversationId, ignoring action');
196 return;
197 }
198
simonf929a362022-11-18 16:53:45 -0500199 setCallStatus(CallStatus.Connecting);
200
simon9076a9a2022-11-29 17:13:01 -0500201 sendWebRtcOffer();
simonf929a362022-11-18 16:53:45 -0500202 };
203
204 webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
205
206 return () => {
207 webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
208 };
209 }
simon9076a9a2022-11-29 17:13:01 -0500210 }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
simonf929a362022-11-18 16:53:45 -0500211
simon9076a9a2022-11-29 17:13:01 -0500212 const endCall = useCallback(() => {
213 const callEnd: CallAction = {
214 contactId: contactUri,
215 conversationId,
216 };
MichelleSS55164202022-11-25 18:36:14 -0500217
simon9076a9a2022-11-29 17:13:01 -0500218 console.info('Sending CallEnd', callEnd);
219 closeConnection();
220 webSocket.send(WebSocketMessageType.CallEnd, callEnd);
simonaccd8022022-11-24 15:04:53 -0500221 navigate(`/conversation/${conversationId}`);
simon9076a9a2022-11-29 17:13:01 -0500222 // TODO: write in chat that the call ended
223 }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
simonaccd8022022-11-24 15:04:53 -0500224
225 useEffect(() => {
simonaccd8022022-11-24 15:04:53 -0500226 const callEndListener = (data: CallAction) => {
227 console.info('Received event on CallEnd', data);
Charliec18d6402022-11-27 13:01:04 -0500228 if (data.conversationId !== conversationId) {
229 console.warn('Wrong incoming conversationId, ignoring action');
230 return;
231 }
232
simon9076a9a2022-11-29 17:13:01 -0500233 closeConnection();
234 navigate(`/conversation/${conversationId}`);
simonaccd8022022-11-24 15:04:53 -0500235 // TODO: write in chat that the call ended
236 };
237
238 webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
239 return () => {
240 webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
241 };
simon9076a9a2022-11-29 17:13:01 -0500242 }, [webSocket, navigate, conversationId, closeConnection]);
simonaccd8022022-11-24 15:04:53 -0500243
simonf929a362022-11-18 16:53:45 -0500244 useEffect(() => {
Charlieb837e8f2022-11-28 19:18:46 -0500245 if (
246 callStatus === CallStatus.Connecting &&
247 (iceConnectionState === 'connected' || iceConnectionState === 'completed')
248 ) {
simonf929a362022-11-18 16:53:45 -0500249 console.info('Changing call status to InCall');
250 setCallStatus(CallStatus.InCall);
Misha Krieger-Raynauldd0cc3e32022-11-29 19:59:31 -0500251 setCallStartTime(Date.now());
simonf929a362022-11-18 16:53:45 -0500252 }
simon9076a9a2022-11-29 17:13:01 -0500253 }, [iceConnectionState, callStatus]);
simonaccd8022022-11-24 15:04:53 -0500254
MichelleSS55164202022-11-25 18:36:14 -0500255 useEffect(() => {
Charlie380dc5e2022-11-29 16:51:42 -0500256 if (iceConnectionState === 'disconnected' || iceConnectionState === 'failed') {
257 console.info('ICE connection disconnected or failed, ending call');
Charlieb837e8f2022-11-28 19:18:46 -0500258 endCall();
259 }
simon9076a9a2022-11-29 17:13:01 -0500260 }, [iceConnectionState, callStatus, isVideoOn, endCall]);
Charlieb837e8f2022-11-28 19:18:46 -0500261
262 useEffect(() => {
MichelleSS55164202022-11-25 18:36:14 -0500263 const checkStatusTimeout = () => {
264 if (callStatus !== CallStatus.InCall) {
265 endCall();
simonff1cb352022-11-24 15:15:26 -0500266 }
MichelleSS55164202022-11-25 18:36:14 -0500267 };
268 const timeoutId = setTimeout(checkStatusTimeout, callTimeoutMs);
simonff1cb352022-11-24 15:15:26 -0500269
MichelleSS55164202022-11-25 18:36:14 -0500270 return () => {
271 clearTimeout(timeoutId);
272 };
273 }, [callStatus, endCall]);
simonff1cb352022-11-24 15:15:26 -0500274
Charlieb837e8f2022-11-28 19:18:46 -0500275 useEffect(() => {
276 navigate('.', {
277 replace: true,
278 state: {},
279 });
280 }, [navigate]);
281
simonff1cb352022-11-24 15:15:26 -0500282 if (!callRole || callStatus === undefined) {
283 console.error('Invalid route. Redirecting...');
simonf929a362022-11-18 16:53:45 -0500284 return <Navigate to={'/'} />;
285 }
286
287 return (
288 <CallContext.Provider
289 value={{
simonf929a362022-11-18 16:53:45 -0500290 isAudioOn,
simon9076a9a2022-11-29 17:13:01 -0500291 setIsAudioOn,
simonf929a362022-11-18 16:53:45 -0500292 isVideoOn,
simon9076a9a2022-11-29 17:13:01 -0500293 setIsVideoOn,
simonf9d78f22022-11-25 15:47:15 -0500294 isChatShown,
295 setIsChatShown,
simon2a5cf142022-11-25 15:47:35 -0500296 isFullscreen,
297 setIsFullscreen,
simonf929a362022-11-18 16:53:45 -0500298 callRole,
299 callStatus,
Gabriel Rochone382a302022-11-23 12:37:04 -0500300 callStartTime,
Charliec444ab72022-11-29 18:24:27 -0500301 isAnswerButtonDisabled,
simonf929a362022-11-18 16:53:45 -0500302 acceptCall,
simonaccd8022022-11-24 15:04:53 -0500303 endCall,
simonf929a362022-11-18 16:53:45 -0500304 }}
305 >
simon9076a9a2022-11-29 17:13:01 -0500306 {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
simonf929a362022-11-18 16:53:45 -0500307 </CallContext.Provider>
308 );
309};