Fix components rerendering unnecessarily

Before, some providers (WebRtcProvider, CallProvider...) were rendered conditionally.
This was causing all their children to re-render when the provider rendered.

Instead of using conditional rendering, these providers now use `ConditionalContextProvider`.
It always renders the provider, but sets its value to `initialValue` if the dependencies are not all defined.
If all dependencies are defined, the value is the result of the `useProviderValue` hook.

Changes:
- New file: `ConditionalContextProvider`
- New file: `HookComponent` - Component that calls a hook when mounted and calls a `callback` function with the hook result as argument
- For `WebRtcProvider` and `CallProvider`, use `createOptionalContext` to create the context and `ConditionalContextProvider` to provide their value only when the conditions are met
- In `WebRtcProvider`, set the webRtcConnection to undefined when not in a call
- For all providers, wrap their `value` prop in `useMemo` to avoid unnecessary rerenders

Change-Id: Ide947e216d54599aabc71cf4bd026bd20d6e0daf
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index b20d0e7..8be6dff 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -16,15 +16,15 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { Navigate } from 'react-router-dom';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
-import LoadingPage from '../components/Loading';
+import { createOptionalContext } from '../hooks/createOptionalContext';
 import { Conversation } from '../models/conversation';
 import { callTimeoutMs } from '../utils/constants';
 import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
-import { CallManagerContext } from './CallManagerProvider';
-import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
+import { CallData, CallManagerContext } from './CallManagerProvider';
+import ConditionalContextProvider from './ConditionalContextProvider';
+import { IWebRtcContext, MediaDevicesInfo, MediaInputKind, useWebRtcContext } from './WebRtcProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
 export type CallRole = 'caller' | 'receiver';
@@ -70,71 +70,53 @@
   endCall: () => void;
 }
 
-const defaultCallContext: ICallContext = {
-  mediaDevices: {
-    audioinput: [],
-    audiooutput: [],
-    videoinput: [],
-  },
-  currentMediaDeviceIds: {
-    audioinput: {
-      id: undefined,
-      setId: async () => {},
-    },
-    audiooutput: {
-      id: undefined,
-      setId: async () => {},
-    },
-    videoinput: {
-      id: undefined,
-      setId: async () => {},
-    },
-  },
-
-  isAudioOn: false,
-  setIsAudioOn: () => {},
-  videoStatus: VideoStatus.Off,
-  updateVideoStatus: () => Promise.reject(),
-  isChatShown: false,
-  setIsChatShown: () => {},
-  isFullscreen: false,
-  setIsFullscreen: () => {},
-  callRole: 'caller',
-  callStatus: CallStatus.Default,
-  callStartTime: undefined,
-
-  acceptCall: (_: boolean) => {},
-  endCall: () => {},
-};
-
-export const CallContext = createContext<ICallContext>(defaultCallContext);
+const optionalCallContext = createOptionalContext<ICallContext>('CallContext');
+export const useCallContext = optionalCallContext.useOptionalContext;
 
 export default ({ children }: WithChildren) => {
   const webSocket = useContext(WebSocketContext);
-  const { callConversation, callData } = useContext(CallManagerContext);
+  const { callConversation, callData, exitCall } = useContext(CallManagerContext);
+  const webRtcContext = useWebRtcContext(true);
 
-  if (!webSocket || !callConversation || !callData?.conversationId) {
-    return <LoadingPage />;
-  }
+  const dependencies = useMemo(
+    () => ({
+      webSocket,
+      webRtcContext,
+      callConversation,
+      callData,
+      exitCall,
+      conversationId: callData?.conversationId,
+    }),
+    [webSocket, webRtcContext, callConversation, callData, exitCall]
+  );
 
   return (
-    <CallProvider webSocket={webSocket} conversation={callConversation} conversationId={callData?.conversationId}>
+    <ConditionalContextProvider
+      Context={optionalCallContext.Context}
+      initialValue={undefined}
+      dependencies={dependencies}
+      useProviderValue={CallProvider}
+    >
       {children}
-    </CallProvider>
+    </ConditionalContextProvider>
   );
 };
 
 const CallProvider = ({
-  children,
-  conversation,
+  webRtcContext,
+  callConversation,
+  callData,
+  exitCall,
   conversationId,
   webSocket,
-}: WithChildren & {
+}: {
   webSocket: IWebSocketContext;
-  conversation: Conversation;
+  webRtcContext: IWebRtcContext;
+  callConversation: Conversation;
+  callData: CallData;
+  exitCall: () => void;
   conversationId: string;
-}) => {
-  const { callData, exitCall } = useContext(CallManagerContext);
+}): ICallContext => {
   const {
     localStream,
     updateScreenShare,
@@ -143,9 +125,13 @@
     closeConnection,
     getMediaDevices,
     updateLocalStream,
-  } = useContext(WebRtcContext);
+  } = webRtcContext;
 
-  const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
+  const [mediaDevices, setMediaDevices] = useState<MediaDevicesInfo>({
+    audioinput: [],
+    audiooutput: [],
+    videoinput: [],
+  });
   const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
   const [audioOutputDeviceId, setAudioOutputDeviceId] = useState<string>();
   const [videoDeviceId, setVideoDeviceId] = useState<string>();
@@ -161,7 +147,7 @@
   // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
   //       The client could make a single request with the conversationId, and the server would be tasked with sending
   //       all the individual requests to the members of the conversation.
-  const contactUri = useMemo(() => conversation.getFirstMember().contact.uri, [conversation]);
+  const contactUri = useMemo(() => callConversation.getFirstMember().contact.uri, [callConversation]);
 
   useEffect(() => {
     if (callStatus !== CallStatus.InCall) {
@@ -406,32 +392,40 @@
     };
   }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
 
-  if (!callData || !callRole) {
-    console.error('Invalid route. Redirecting...');
-    return <Navigate to={'/'} />;
-  }
-
-  return (
-    <CallContext.Provider
-      value={{
-        mediaDevices,
-        currentMediaDeviceIds,
-        isAudioOn,
-        setIsAudioOn,
-        videoStatus,
-        updateVideoStatus,
-        isChatShown,
-        setIsChatShown,
-        isFullscreen,
-        setIsFullscreen,
-        callRole,
-        callStatus,
-        callStartTime,
-        acceptCall,
-        endCall,
-      }}
-    >
-      {children}
-    </CallContext.Provider>
+  return useMemo(
+    () => ({
+      mediaDevices,
+      currentMediaDeviceIds,
+      isAudioOn,
+      setIsAudioOn,
+      videoStatus,
+      updateVideoStatus,
+      isChatShown,
+      setIsChatShown,
+      isFullscreen,
+      setIsFullscreen,
+      callRole,
+      callStatus,
+      callStartTime,
+      acceptCall,
+      endCall,
+    }),
+    [
+      mediaDevices,
+      currentMediaDeviceIds,
+      isAudioOn,
+      setIsAudioOn,
+      videoStatus,
+      updateVideoStatus,
+      isChatShown,
+      setIsChatShown,
+      isFullscreen,
+      setIsFullscreen,
+      callRole,
+      callStatus,
+      callStartTime,
+      acceptCall,
+      endCall,
+    ]
   );
 };