Transform CallProvider into 'CallManager' hook, review WebSocket messages for calls

- These changes allowed to remove more cascading effects. It is now possible to reactivate StrictMode. Downside is we lost the 'optional context' of CallProvider: the call logic will be loaded even if there is no call.
- The WebSocket messages have been changed so the client does not have to know the conversation members before a call. Previously, the client had to fetch the conversation members for a call, which was causing cascading effects.
- Accidentally, moving the handling of conversation members to the server added some logic for calls with more than two participants, but it is still not ready to work.

* CallProvider.tsx will be renamed in next commit in order to make it easier to track its file history

Change-Id: Iae711009adafce065ac3defc1c91c7ca0f37898c
diff --git a/client/src/components/CallButtons.tsx b/client/src/components/CallButtons.tsx
index d853cc1..0ee463e 100644
--- a/client/src/components/CallButtons.tsx
+++ b/client/src/components/CallButtons.tsx
@@ -20,7 +20,8 @@
 import { styled } from '@mui/material/styles';
 import { ChangeEvent, useMemo } from 'react';
 
-import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
+import { CallStatus, VideoStatus } from '../contexts/CallProvider';
 import { useUserMediaContext } from '../contexts/UserMediaProvider';
 import {
   ColoredRoundButton,
@@ -58,7 +59,7 @@
 });
 
 export const CallingChatButton = (props: ExpandableButtonProps) => {
-  const { setIsChatShown } = useCallContext();
+  const { setIsChatShown } = useCallManagerContext();
   return (
     <CallButton
       aria-label="chat"
@@ -72,7 +73,7 @@
 };
 
 export const CallingEndButton = (props: ExpandableButtonProps) => {
-  const { endCall } = useCallContext();
+  const { endCall } = useCallManagerContext();
   return (
     <ColoredRoundButton
       paletteColor={(theme) => theme.palette.error}
@@ -91,7 +92,7 @@
 };
 
 export const CallingFullScreenButton = (props: ExpandableButtonProps) => {
-  const { setIsFullscreen } = useCallContext();
+  const { setIsFullscreen } = useCallManagerContext();
   return (
     <CallButton
       aria-label="full screen"
@@ -123,7 +124,7 @@
 };
 
 const ToggleScreenShareIconButton = (props: IconButtonProps) => {
-  const { videoStatus, updateVideoStatus } = useCallContext();
+  const { videoStatus, updateVideoStatus } = useCallManagerContext();
 
   return (
     <ToggleIconButton
@@ -194,7 +195,7 @@
 };
 
 const ToggleAudioCameraIconButton = (props: IconButtonProps) => {
-  const { isAudioOn, setIsAudioOn } = useCallContext();
+  const { isAudioOn, setIsAudioOn } = useCallManagerContext();
   return (
     <ToggleIconButton
       IconOn={MicroIcon}
@@ -220,7 +221,7 @@
 };
 
 const ToggleVideoCameraIconButton = (props: IconButtonProps) => {
-  const { videoStatus, updateVideoStatus } = useCallContext();
+  const { videoStatus, updateVideoStatus } = useCallManagerContext();
   return (
     <ToggleIconButton
       IconOn={VideoCameraIcon}
@@ -236,7 +237,7 @@
 
 // Calling pending/receiving interface
 export const CallingCancelButton = (props: IconButtonProps) => {
-  const { endCall } = useCallContext();
+  const { endCall } = useCallManagerContext();
 
   return (
     <ColoredRoundButton
@@ -252,7 +253,7 @@
 };
 
 export const CallingAnswerAudioButton = (props: IconButtonProps) => {
-  const { acceptCall, callStatus } = useCallContext();
+  const { acceptCall, callStatus } = useCallManagerContext();
 
   return (
     <ColoredRoundButton
@@ -269,7 +270,7 @@
 };
 
 export const CallingAnswerVideoButton = (props: IconButtonProps) => {
-  const { acceptCall, callStatus } = useCallContext();
+  const { acceptCall, callStatus } = useCallManagerContext();
   return (
     <ColoredRoundButton
       disabled={callStatus === CallStatus.Connecting || callStatus === CallStatus.Loading}
@@ -285,7 +286,7 @@
 };
 
 export const CallingRefuseButton = (props: IconButtonProps) => {
-  const { endCall } = useCallContext();
+  const { endCall } = useCallManagerContext();
   return (
     <ColoredRoundButton
       aria-label="refuse call"
diff --git a/client/src/components/CallChatDrawer.tsx b/client/src/components/CallChatDrawer.tsx
index d094554..4a58393 100644
--- a/client/src/components/CallChatDrawer.tsx
+++ b/client/src/components/CallChatDrawer.tsx
@@ -17,7 +17,7 @@
  */
 import { Divider, Stack, Typography } from '@mui/material';
 
-import { useCallContext } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import ChatInterface from '../pages/ChatInterface';
 import { CloseButton } from './Button';
@@ -43,7 +43,7 @@
 };
 
 const CallChatDrawerHeader = () => {
-  const { setIsChatShown } = useCallContext();
+  const { setIsChatShown } = useCallManagerContext();
   const { conversationDisplayName } = useConversationContext();
 
   return (
diff --git a/client/src/components/ConversationSummaryList.tsx b/client/src/components/ConversationSummaryList.tsx
index f8e3d22..c50a5f9 100644
--- a/client/src/components/ConversationSummaryList.tsx
+++ b/client/src/components/ConversationSummaryList.tsx
@@ -19,13 +19,13 @@
 import dayjs from 'dayjs';
 import { IConversationSummary } from 'jami-web-common';
 import { QRCodeCanvas } from 'qrcode.react';
-import { useCallback, useContext, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useNavigate } from 'react-router-dom';
 
 import { useAuthContext } from '../contexts/AuthProvider';
-import { CallManagerContext } from '../contexts/CallManagerProvider';
-import { CallStatus, useCallContext } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
+import { CallStatus } from '../contexts/CallProvider';
 import { useConversationDisplayNameShort } from '../hooks/useConversationDisplayName';
 import { useUrlParams } from '../hooks/useUrlParams';
 import { ConversationRouteParams } from '../router';
@@ -108,8 +108,7 @@
 
 const SecondaryText = ({ conversationSummary, isSelected }: SecondaryTextProps) => {
   const { account } = useAuthContext();
-  const callContext = useCallContext(true);
-  const { callData } = useContext(CallManagerContext);
+  const { callData, callStatus, isAudioOn } = useCallManagerContext();
   const { t, i18n } = useTranslation();
 
   const timeIndicator = useMemo(() => {
@@ -123,7 +122,7 @@
   }, [conversationSummary, i18n]);
 
   const lastMessageText = useMemo(() => {
-    if (!callContext || !callData || callData.conversationId !== conversationSummary.id) {
+    if (!callData || callData.conversationId !== conversationSummary.id) {
       const message = conversationSummary.lastMessage;
       switch (message.type) {
         case 'initial': {
@@ -149,16 +148,16 @@
       }
     }
 
-    if (callContext.callStatus === CallStatus.InCall) {
-      return callContext.isAudioOn ? t('ongoing_call_unmuted') : t('ongoing_call_muted');
+    if (callStatus === CallStatus.InCall) {
+      return isAudioOn ? t('ongoing_call_unmuted') : t('ongoing_call_muted');
     }
 
-    if (callContext.callStatus === CallStatus.Connecting) {
+    if (callStatus === CallStatus.Connecting) {
       return t('connecting_call');
     }
 
-    return callContext.callRole === 'caller' ? t('outgoing_call') : t('incoming_call');
-  }, [account, conversationSummary, callContext, callData, t, i18n]);
+    return callData.role === 'caller' ? t('outgoing_call') : t('incoming_call');
+  }, [account, conversationSummary, callData, callStatus, isAudioOn, t, i18n]);
 
   return (
     <Stack direction="row" spacing="5px">
@@ -186,7 +185,7 @@
   contextMenuProps,
 }: ConversationMenuProps) => {
   const { t } = useTranslation();
-  const { startCall } = useContext(CallManagerContext);
+  const { startCall } = useCallManagerContext();
   const [isSwarm] = useState(true);
 
   const detailsDialogHandler = useDialogHandler();
@@ -206,10 +205,7 @@
         Icon: AudioCallIcon,
         onClick: () => {
           if (conversationId) {
-            startCall({
-              conversationId,
-              role: 'caller',
-            });
+            startCall(conversationId);
           }
         },
       },
@@ -218,11 +214,7 @@
         Icon: VideoCallIcon,
         onClick: () => {
           if (conversationId) {
-            startCall({
-              conversationId,
-              role: 'caller',
-              withVideoOn: true,
-            });
+            startCall(conversationId, true);
           }
         },
       },
diff --git a/client/src/components/ConversationView.tsx b/client/src/components/ConversationView.tsx
index 5f76add..b636ec3 100644
--- a/client/src/components/ConversationView.tsx
+++ b/client/src/components/ConversationView.tsx
@@ -16,10 +16,8 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Divider, Stack, Typography } from '@mui/material';
-import { useContext } from 'react';
 
-import { CallManagerContext } from '../contexts/CallManagerProvider';
-import { useCallContext } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import CallInterface from '../pages/CallInterface';
 import ChatInterface from '../pages/ChatInterface';
@@ -27,10 +25,9 @@
 
 const ConversationView = () => {
   const { conversationId } = useConversationContext();
-  const callContext = useCallContext(true);
-  const { callData } = useContext(CallManagerContext);
+  const { callData } = useCallManagerContext();
 
-  if (callContext && callData?.conversationId === conversationId) {
+  if (callData?.conversationId === conversationId) {
     return <CallInterface />;
   }
 
@@ -49,7 +46,7 @@
 
 const ConversationHeader = () => {
   const { conversationId, conversationDisplayName } = useConversationContext();
-  const { startCall } = useContext(CallManagerContext);
+  const { startCall } = useCallManagerContext();
 
   return (
     <Stack direction="row" padding="16px" overflow="hidden">
@@ -59,8 +56,8 @@
         </Typography>
       </Stack>
       <Stack direction="row" spacing="20px">
-        <StartAudioCallButton onClick={() => startCall({ conversationId, role: 'caller' })} />
-        <StartVideoCallButton onClick={() => startCall({ conversationId, role: 'caller', withVideoOn: true })} />
+        <StartAudioCallButton onClick={() => startCall(conversationId)} />
+        <StartVideoCallButton onClick={() => startCall(conversationId, true)} />
         <AddParticipantButton />
         <ShowOptionsMenuButton />
       </Stack>
diff --git a/client/src/components/VideoOverlay.tsx b/client/src/components/VideoOverlay.tsx
index 9307f9b..2093840 100644
--- a/client/src/components/VideoOverlay.tsx
+++ b/client/src/components/VideoOverlay.tsx
@@ -20,7 +20,7 @@
 import Draggable, { DraggableEventHandler } from 'react-draggable';
 import { useNavigate } from 'react-router-dom';
 
-import { useCallContext } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
 import { useUserMediaContext } from '../contexts/UserMediaProvider';
 import { VideoElementWithSinkId } from '../utils/utils';
 import VideoStream, { VideoStreamProps } from './VideoStream';
@@ -75,7 +75,7 @@
 };
 
 export const RemoteVideoOverlay = ({ callConversationId }: { callConversationId: string }) => {
-  const { remoteStreams } = useCallContext();
+  const { remoteStreams } = useCallManagerContext();
   const {
     currentMediaDeviceIds: {
       audiooutput: { id: audioOutDeviceId },
diff --git a/client/src/contexts/CallManagerProvider.tsx b/client/src/contexts/CallManagerProvider.tsx
index ec68765..8f44a1b 100644
--- a/client/src/contexts/CallManagerProvider.tsx
+++ b/client/src/contexts/CallManagerProvider.tsx
@@ -15,117 +15,28 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { CallBegin, ConversationInfos, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { useNavigate } from 'react-router-dom';
-
 import { RemoteVideoOverlay } from '../components/VideoOverlay';
+import { createOptionalContext } from '../hooks/createOptionalContext';
 import { useUrlParams } from '../hooks/useUrlParams';
-import { ConversationMember } from '../models/conversation-member';
 import { ConversationRouteParams } from '../router';
-import { useConversationInfosQuery, useMembersQuery } from '../services/conversationQueries';
-import { SetState, WithChildren } from '../utils/utils';
-import { AlertSnackbarContext } from './AlertSnackbarProvider';
-import CallProvider, { CallRole } from './CallProvider';
-import { useWebSocketContext } from './WebSocketProvider';
+import { WithChildren } from '../utils/utils';
+import { ICallManager, useCallManager } from './CallProvider';
 
-export type CallData = {
-  conversationId: string;
-  role: CallRole;
-  withVideoOn?: boolean;
-};
-
-type ICallManagerContext = {
-  callData: CallData | undefined;
-  callConversationInfos: ConversationInfos | undefined;
-  callMembers: ConversationMember[] | undefined;
-  startCall: SetState<CallData | undefined>;
-  exitCall: () => void;
-};
-
-const defaultCallManagerContext: ICallManagerContext = {
-  callData: undefined,
-  callConversationInfos: undefined,
-  callMembers: undefined,
-  startCall: () => {},
-  exitCall: () => {},
-};
-
-export const CallManagerContext = createContext<ICallManagerContext>(defaultCallManagerContext);
-CallManagerContext.displayName = 'CallManagerContext';
+const optionalCallManagerContext = createOptionalContext<ICallManager>('CallManagerContext');
+export const useCallManagerContext = optionalCallManagerContext.useOptionalContext;
 
 export default ({ children }: WithChildren) => {
-  const [callData, setCallData] = useState<CallData>();
-  const { setAlertContent } = useContext(AlertSnackbarContext);
-  const webSocket = useWebSocketContext();
-  const navigate = useNavigate();
-  const { data: conversationInfos } = useConversationInfosQuery(callData?.conversationId);
-  const { data: members } = useMembersQuery(callData?.conversationId);
   const { urlParams } = useUrlParams<ConversationRouteParams>();
-  const { t } = useTranslation();
+  const callManager = useCallManager();
 
-  const failStartCall = useCallback(() => {
-    throw new Error('Cannot start call: Already in a call');
-  }, []);
-
-  const startCall = !callData ? setCallData : failStartCall;
-
-  const exitCall = useCallback(() => {
-    if (!callData) {
-      return;
-    }
-
-    setCallData(undefined);
-    // TODO: write in chat that the call ended
-  }, [callData]);
-
-  useEffect(() => {
-    const callBeginListener = ({ conversationId, withVideoOn }: CallBegin) => {
-      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;
-      }
-
-      startCall({ conversationId: conversationId, role: 'receiver', withVideoOn });
-      navigate(`/conversation/${conversationId}`);
-    };
-
-    webSocket.bind(WebSocketMessageType.CallBegin, callBeginListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.CallBegin, callBeginListener);
-    };
-  }, [webSocket, navigate, startCall, callData, setAlertContent, t]);
-
-  const value = useMemo(
-    () => ({
-      startCall,
-      callData,
-      callConversationInfos: conversationInfos,
-      callMembers: members,
-      exitCall,
-    }),
-    [startCall, callData, conversationInfos, members, exitCall]
-  );
+  const { callData } = callManager;
 
   return (
-    <>
-      <CallManagerContext.Provider value={value}>
-        <CallProvider>
-          {callData && callData.conversationId !== urlParams.conversationId && (
-            <RemoteVideoOverlay callConversationId={callData.conversationId} />
-          )}
-          {children}
-        </CallProvider>
-      </CallManagerContext.Provider>
-    </>
+    <optionalCallManagerContext.Context.Provider value={callManager}>
+      {callData && callData.conversationId !== urlParams.conversationId && (
+        <RemoteVideoOverlay callConversationId={callData.conversationId} />
+      )}
+      {children}
+    </optionalCallManagerContext.Context.Provider>
   );
 };
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index 1a8c5b0..61379a9 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -15,21 +15,27 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
+import { ConversationInfos, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
 import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
 
-import { createOptionalContext } from '../hooks/createOptionalContext';
 import { ConversationMember } from '../models/conversation-member';
+import { useConversationInfosQuery, useMembersQuery } from '../services/conversationQueries';
 import { callTimeoutMs } from '../utils/constants';
-import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
+import { AsyncSetState, SetState } from '../utils/utils';
 import { useWebRtcManager } from '../webrtc/WebRtcManager';
+import { AlertSnackbarContext } from './AlertSnackbarProvider';
 import { useAuthContext } from './AuthProvider';
-import { CallData, CallManagerContext } from './CallManagerProvider';
-import ConditionalContextProvider from './ConditionalContextProvider';
 import { useUserMediaContext } from './UserMediaProvider';
-import { IWebSocketContext, useWebSocketContext } from './WebSocketProvider';
+import { useWebSocketContext } from './WebSocketProvider';
 
-export type CallRole = 'caller' | 'receiver';
+export type CallRole = 'caller' | 'receiver' | undefined;
+
+export type CallData = {
+  conversationId: string;
+  role: CallRole;
+  withVideoOn?: boolean;
+};
 
 export enum CallStatus {
   Default,
@@ -46,7 +52,11 @@
   ScreenShare,
 }
 
-export interface ICallContext {
+export interface ICallManager {
+  callData: CallData | undefined;
+  callConversationInfos: ConversationInfos | undefined;
+  callMembers: ConversationMember[] | undefined;
+
   remoteStreams: readonly MediaStream[];
 
   isAudioOn: boolean;
@@ -57,57 +67,22 @@
   setIsChatShown: SetState<boolean>;
   isFullscreen: boolean;
   setIsFullscreen: SetState<boolean>;
-  callRole: CallRole;
   callStatus: CallStatus;
   callStartTime: number | undefined;
 
+  startCall: (conversationId: string, withVideoOn?: boolean) => void;
   acceptCall: (withVideoOn: boolean) => void;
   endCall: () => void;
 }
 
-const optionalCallContext = createOptionalContext<ICallContext>('CallContext');
-export const useCallContext = optionalCallContext.useOptionalContext;
-
-export default ({ children }: WithChildren) => {
+export const useCallManager = () => {
+  const { setAlertContent } = useContext(AlertSnackbarContext);
+  const [callData, setCallData] = useState<CallData>();
   const webSocket = useWebSocketContext();
-  const { callMembers, callData, exitCall } = useContext(CallManagerContext);
+  const navigate = useNavigate();
+  const { data: callConversationInfos } = useConversationInfosQuery(callData?.conversationId);
+  const { data: callMembers } = useMembersQuery(callData?.conversationId);
 
-  const dependencies = useMemo(
-    () => ({
-      webSocket,
-      callMembers,
-      callData,
-      exitCall,
-      conversationId: callData?.conversationId,
-    }),
-    [webSocket, callMembers, callData, exitCall]
-  );
-
-  return (
-    <ConditionalContextProvider
-      Context={optionalCallContext.Context}
-      initialValue={undefined}
-      dependencies={dependencies}
-      useProviderValue={CallProvider}
-    >
-      {children}
-    </ConditionalContextProvider>
-  );
-};
-
-const CallProvider = ({
-  callMembers,
-  callData,
-  exitCall,
-  conversationId,
-  webSocket,
-}: {
-  webSocket: IWebSocketContext;
-  callMembers: ConversationMember[];
-  callData: CallData;
-  exitCall: () => void;
-  conversationId: string;
-}): ICallContext => {
   const {
     localStream,
     updateLocalStream,
@@ -120,28 +95,43 @@
   const { account } = useAuthContext();
   const webRtcManager = useWebRtcManager();
 
-  // 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 = callMembers[0]?.contact.uri;
-  const connectionInfos = webRtcManager.connectionsInfos[contactUri];
-  const remoteStreams = connectionInfos?.remoteStreams;
-  const iceConnectionState = connectionInfos?.iceConnectionState;
-
   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 [callRole] = useState(callData?.role);
   const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
 
-  // TODO: Replace this by a callback
   useEffect(() => {
-    if (callData.role === 'receiver' && contactUri && localStream) {
-      webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
-    }
-  }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
+    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;
@@ -153,12 +143,6 @@
     updateLocalStreams(localStream, screenShareLocalStream);
   }, [localStream, screenShareLocalStream, updateLocalStreams]);
 
-  const sendWebRtcOffer = useCallback(async () => {
-    if (contactUri) {
-      webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
-    }
-  }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
-
   const closeConnection = useCallback(() => {
     stopMedias();
     webRtcManager.clean();
@@ -223,57 +207,82 @@
     };
   }, []);
 
-  useEffect(() => {
-    if (callRole === 'caller' && callStatus === CallStatus.Default) {
-      const withVideoOn = callData?.withVideoOn ?? false;
-      setCallStatus(CallStatus.Loading);
-      updateLocalStream()
-        .then(() => {
-          const callBegin: CallBegin = {
-            contactId: contactUri,
-            conversationId,
-            withVideoOn,
-          };
+  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);
-          console.info('Sending CallBegin', callBegin);
-          webSocket.send(WebSocketMessageType.CallBegin, callBegin);
-        })
-        .catch((e) => {
-          console.error(e);
-          setCallStatus(CallStatus.PermissionsDenied);
-        });
-    }
-  }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, callData]);
+            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: CallAction = {
-            contactId: contactUri,
+          const callAccept: WebSocketMessageTable['sendCallJoin'] = {
             conversationId,
           };
 
           setVideoStatus(withVideoOn ? VideoStatus.Camera : VideoStatus.Off);
           setCallStatus(CallStatus.Connecting);
-          console.info('Sending CallAccept', callAccept);
-          webSocket.send(WebSocketMessageType.CallAccept, callAccept);
+          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);
         });
     },
-    [webSocket, updateLocalStream, contactUri, conversationId]
+    [
+      account,
+      callData,
+      conversationId,
+      localStream,
+      callMembers,
+      screenShareLocalStream,
+      updateLocalStream,
+      webRtcManager,
+      webSocket,
+    ]
   );
 
   useEffect(() => {
-    if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
-      const callAcceptListener = (data: CallAction) => {
-        console.info('Received event on CallAccept', data);
+    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;
@@ -281,48 +290,51 @@
 
         setCallStatus(CallStatus.Connecting);
 
-        sendWebRtcOffer();
+        webRtcManager.addConnection(webSocket, account, data.senderId, callData, localStream, screenShareLocalStream);
       };
 
-      webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
+      webSocket.bind(WebSocketMessageType.onCallJoin, callJoinListener);
 
       return () => {
-        webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
+        webSocket.unbind(WebSocketMessageType.onCallJoin, callJoinListener);
       };
     }
-  }, [callRole, webSocket, sendWebRtcOffer, callStatus, conversationId]);
+  }, [account, callData, callStatus, conversationId, localStream, screenShareLocalStream, webRtcManager, webSocket]);
 
   const endCall = useCallback(() => {
-    const callEnd: CallAction = {
-      contactId: contactUri,
+    if (!conversationId) {
+      return;
+    }
+
+    const callExit: WebSocketMessageTable['sendCallExit'] = {
       conversationId,
     };
 
-    console.info('Sending CallEnd', callEnd);
+    console.info('Sending CallExit', callExit);
     closeConnection();
-    webSocket.send(WebSocketMessageType.CallEnd, callEnd);
-    exitCall();
+    webSocket.send(WebSocketMessageType.sendCallExit, callExit);
+    setCallData(undefined);
+    setCallStatus(CallStatus.Default);
     // TODO: write in chat that the call ended
-  }, [webSocket, contactUri, conversationId, closeConnection, exitCall]);
+  }, [webSocket, conversationId, closeConnection]);
 
   useEffect(() => {
-    const callEndListener = (data: CallAction) => {
-      console.info('Received event on CallEnd', data);
+    const callExitListener = (data: WebSocketMessageTable['onCallExit']) => {
+      console.info('Received event on CallExit', data);
       if (data.conversationId !== conversationId) {
         console.warn('Wrong incoming conversationId, ignoring action');
         return;
       }
 
-      closeConnection();
-      exitCall();
+      endCall();
       // TODO: write in chat that the call ended
     };
 
-    webSocket.bind(WebSocketMessageType.CallEnd, callEndListener);
+    webSocket.bind(WebSocketMessageType.onCallExit, callExitListener);
     return () => {
-      webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
+      webSocket.unbind(WebSocketMessageType.onCallExit, callExitListener);
     };
-  }, [webSocket, exitCall, conversationId, closeConnection]);
+  }, [webSocket, endCall, conversationId]);
 
   useEffect(() => {
     if (
@@ -357,6 +369,9 @@
 
   return useMemo(
     () => ({
+      callData,
+      callConversationInfos,
+      callMembers,
       remoteStreams,
       isAudioOn,
       setIsAudioOn,
@@ -366,13 +381,16 @@
       setIsChatShown,
       isFullscreen,
       setIsFullscreen,
-      callRole,
       callStatus,
       callStartTime,
+      startCall,
       acceptCall,
       endCall,
     }),
     [
+      callData,
+      callConversationInfos,
+      callMembers,
       remoteStreams,
       isAudioOn,
       videoStatus,
@@ -382,9 +400,9 @@
       setIsChatShown,
       isFullscreen,
       setIsFullscreen,
-      callRole,
       callStatus,
       callStartTime,
+      startCall,
       acceptCall,
       endCall,
     ]
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index f66a180..2892bb7 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -48,7 +48,8 @@
 import CallChatDrawer from '../components/CallChatDrawer';
 import VideoOverlay from '../components/VideoOverlay';
 import VideoStream from '../components/VideoStream';
-import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
+import { CallStatus, VideoStatus } from '../contexts/CallProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { useUserMediaContext } from '../contexts/UserMediaProvider';
 import { formatCallDuration } from '../utils/dates&times';
@@ -57,7 +58,7 @@
 import CallPermissionDenied from './CallPermissionDenied';
 
 export default () => {
-  const { callStatus, isChatShown, isFullscreen } = useCallContext();
+  const { callStatus, isChatShown, isFullscreen } = useCallManagerContext();
   const callInterfaceRef = useRef<HTMLDivElement>();
 
   useEffect(() => {
@@ -99,7 +100,7 @@
       audiooutput: { id: audioOutDeviceId },
     },
   } = useUserMediaContext();
-  const { remoteStreams, videoStatus } = useCallContext();
+  const { remoteStreams, videoStatus } = useCallManagerContext();
   const remoteVideoRef = useRef<VideoElementWithSinkId | null>(null);
   const gridItemRef = useRef<HTMLDivElement | null>(null);
   const [isLocalVideoZoomed, setIsLocalVideoZoomed] = useState(false);
@@ -156,7 +157,7 @@
 };
 
 const CallInterfaceInformation = () => {
-  const { callStartTime } = useCallContext();
+  const { callStartTime } = useCallManagerContext();
   const { conversationDisplayName } = useConversationContext();
   const [elapsedTime, setElapsedTime] = useState<Duration>(
     dayjs.duration(callStartTime ? Date.now() - callStartTime : 0)
diff --git a/client/src/pages/CallPending.tsx b/client/src/pages/CallPending.tsx
index c965d45..0ff1b9e 100644
--- a/client/src/pages/CallPending.tsx
+++ b/client/src/pages/CallPending.tsx
@@ -28,7 +28,8 @@
   CallingRefuseButton,
 } from '../components/CallButtons';
 import ConversationAvatar from '../components/ConversationAvatar';
-import { CallStatus, useCallContext } from '../contexts/CallProvider';
+import { useCallManagerContext } from '../contexts/CallManagerProvider';
+import { CallStatus } from '../contexts/CallProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { useUserMediaContext } from '../contexts/UserMediaProvider';
 import { VideoElementWithSinkId } from '../utils/utils';
@@ -36,7 +37,7 @@
 export const CallPending = () => {
   const { conversationDisplayName } = useConversationContext();
   const { localStream } = useUserMediaContext();
-  const { callRole } = useCallContext();
+  const { callData } = useCallManagerContext();
   const localVideoRef = useRef<VideoElementWithSinkId | null>(null);
 
   useEffect(() => {
@@ -107,7 +108,7 @@
           />
         </Box>
       </Box>
-      {callRole === 'caller' ? <CallPendingCallerInterface /> : <CallPendingReceiverInterface />}
+      {callData?.role === 'caller' ? <CallPendingCallerInterface /> : <CallPendingReceiverInterface />}
     </Stack>
   );
 };
@@ -146,7 +147,7 @@
 };
 
 export const CallPendingCallerInterface = () => {
-  const { callStatus } = useCallContext();
+  const { callStatus } = useCallManagerContext();
   const { t } = useTranslation();
   const { members } = useConversationContext();
   const memberName = useMemo(() => members[0].getDisplayName(), [members]);
@@ -179,7 +180,7 @@
 
 export const CallPendingReceiverInterface = () => {
   const { state } = useLocation();
-  const { callStatus } = useCallContext();
+  const { callStatus } = useCallManagerContext();
 
   const { t } = useTranslation();
   const { members } = useConversationContext();
diff --git a/client/src/webrtc/RtcPeerConnectionHandler.ts b/client/src/webrtc/RtcPeerConnectionHandler.ts
index 8d83e5d..ddf2f6f 100644
--- a/client/src/webrtc/RtcPeerConnectionHandler.ts
+++ b/client/src/webrtc/RtcPeerConnectionHandler.ts
@@ -16,9 +16,9 @@
  * <https://www.gnu.org/licenses/>.
  */
 
-import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
+import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
 
-import { CallData } from '../contexts/CallManagerProvider';
+import { CallData } from '../contexts/CallProvider';
 import { IWebSocketContext } from '../contexts/WebSocketProvider';
 import { Account } from '../models/account';
 import { Listener } from '../utils/utils';
@@ -65,6 +65,7 @@
     screenShareLocalStream: MediaStream | undefined,
     listener: Listener
   ) {
+    console.log('constructor', callData);
     this.listener = listener;
     const iceServers = this.getIceServers(account);
     this.connection = new RTCPeerConnection({ iceServers });
@@ -159,15 +160,15 @@
       offerToReceiveVideo: true,
     });
 
-    const webRtcOffer: WebRtcSdp = {
-      contactId: contactUri,
+    const webRtcOffer: WebSocketMessageTable['sendWebRtcOffer'] = {
+      receiverId: contactUri,
       conversationId: conversationId,
       sdp,
     };
 
     await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
     console.info('Sending WebRtcOffer', webRtcOffer);
-    webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
+    webSocket.send(WebSocketMessageType.sendWebRtcOffer, webRtcOffer);
   }
 
   private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
@@ -177,15 +178,15 @@
         offerToReceiveVideo: true,
       });
 
-      const webRtcAnswer: WebRtcSdp = {
-        contactId: contactUri,
+      const webRtcAnswer: WebSocketMessageTable['sendWebRtcAnswer'] = {
+        receiverId: contactUri,
         conversationId: conversationId,
         sdp,
       };
 
       await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
       console.info('Sending WebRtcAnswer', webRtcAnswer);
-      webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
+      webSocket.send(WebSocketMessageType.sendWebRtcAnswer, webRtcAnswer);
     };
 
     const addQueuedIceCandidates = async () => {
@@ -243,28 +244,28 @@
       }
     };
 
-    webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
-    webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
-    webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+    webSocket.bind(WebSocketMessageType.onWebRtcOffer, webRtcOfferListener);
+    webSocket.bind(WebSocketMessageType.onWebRtcAnswer, webRtcAnswerListener);
+    webSocket.bind(WebSocketMessageType.onWebRtcIceCandidate, webRtcIceCandidateListener);
 
     this.cleaningFunctions.push(() => {
-      webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
-      webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
-      webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+      webSocket.unbind(WebSocketMessageType.onWebRtcOffer, webRtcOfferListener);
+      webSocket.unbind(WebSocketMessageType.onWebRtcAnswer, webRtcAnswerListener);
+      webSocket.unbind(WebSocketMessageType.onWebRtcIceCandidate, webRtcIceCandidateListener);
     });
   }
 
   private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
     this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
       if (event.candidate) {
-        const webRtcIceCandidate: WebRtcIceCandidate = {
-          contactId: contactUri,
+        const webRtcIceCandidate: WebSocketMessageTable['sendWebRtcIceCandidate'] = {
+          receiverId: contactUri,
           conversationId: conversationId,
           candidate: event.candidate,
         };
 
         // Send ice candidates as soon as they're found. This is called "trickle ice"
-        webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
+        webSocket.send(WebSocketMessageType.sendWebRtcIceCandidate, webRtcIceCandidate);
       }
     };
 
diff --git a/client/src/webrtc/WebRtcManager.ts b/client/src/webrtc/WebRtcManager.ts
index afae8a0..67a614b 100644
--- a/client/src/webrtc/WebRtcManager.ts
+++ b/client/src/webrtc/WebRtcManager.ts
@@ -17,7 +17,7 @@
  */
 import { useMemo, useRef, useSyncExternalStore } from 'react';
 
-import { CallData } from '../contexts/CallManagerProvider';
+import { CallData } from '../contexts/CallProvider';
 import { IWebSocketContext } from '../contexts/WebSocketProvider';
 import { Account } from '../models/account';
 import { Listener } from '../utils/utils';
@@ -107,5 +107,7 @@
 
   clean() {
     Object.values(this.connections).forEach((connection) => connection.disconnect());
+    this.connections = {};
+    this.emitChange();
   }
 }