Change conversation during call

- Add `CallManagerProvider` to manage calls when the user navigates away from the call interface.
- Delete `NotificationManager`. Move its logic to `CallManagerProvider`.
- Rework routing
- Rework `ConversationProvider` and `useConversationQuery` to remove
  unecessary states

GitLab: #172
Change-Id: I4a786a3dd52159680e5712e598d9b831525fb63f
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index d94733d..3f5dad3 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -23,9 +23,9 @@
 import { useNavigate } from 'react-router-dom';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { CallManagerContext } from '../contexts/CallManagerProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { MessengerContext } from '../contexts/MessengerProvider';
-import { useStartCall } from '../hooks/useStartCall';
 import { Conversation } from '../models/conversation';
 import { setRefreshFromSlice } from '../redux/appSlice';
 import { useAppDispatch } from '../redux/hooks';
@@ -104,6 +104,7 @@
 }: ConversationMenuProps) => {
   const { t } = useTranslation();
   const { axiosInstance } = useAuthContext();
+  const { startCall } = useContext(CallManagerContext);
   const [isSwarm] = useState(true);
 
   const detailsDialogHandler = useDialogHandler();
@@ -112,8 +113,6 @@
 
   const navigate = useNavigate();
 
-  const startCall = useStartCall();
-
   const getContactDetails = useCallback(async () => {
     const controller = new AbortController();
     try {
@@ -140,7 +139,10 @@
         Icon: AudioCallIcon,
         onClick: () => {
           if (conversationId) {
-            startCall(conversationId);
+            startCall({
+              conversationId,
+              role: 'caller',
+            });
           }
         },
       },
@@ -149,8 +151,10 @@
         Icon: VideoCallIcon,
         onClick: () => {
           if (conversationId) {
-            startCall(conversationId, {
-              isVideoOn: true,
+            startCall({
+              conversationId,
+              role: 'caller',
+              withVideoOn: true,
             });
           }
         },
diff --git a/client/src/components/ConversationView.tsx b/client/src/components/ConversationView.tsx
index c9a19a2..b3deed4 100644
--- a/client/src/components/ConversationView.tsx
+++ b/client/src/components/ConversationView.tsx
@@ -16,18 +16,26 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Divider, Stack, Typography } from '@mui/material';
-import { useMemo } from 'react';
+import { useContext, useMemo } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { CallManagerContext } from '../contexts/CallManagerProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
-import { useStartCall } from '../hooks/useStartCall';
 import { ConversationMember } from '../models/conversation';
+import CallInterface from '../pages/CallInterface';
 import ChatInterface from '../pages/ChatInterface';
 import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
 import { AddParticipantButton, ShowOptionsMenuButton, StartAudioCallButton, StartVideoCallButton } from './Button';
 
 const ConversationView = () => {
+  const { conversationId } = useConversationContext();
+  const { callData } = useContext(CallManagerContext);
+
+  if (callData && callData.conversationId === conversationId) {
+    return <CallInterface />;
+  }
+
   return (
     <Stack flexGrow={1} height="100%">
       <ConversationHeader />
@@ -44,6 +52,7 @@
 const ConversationHeader = () => {
   const { account } = useAuthContext();
   const { conversation, conversationId } = useConversationContext();
+  const { startCall } = useContext(CallManagerContext);
   const { t } = useTranslation();
 
   const members = conversation.members;
@@ -72,8 +81,6 @@
     return translateEnumeration<ConversationMember>(members, options);
   }, [account, members, adminTitle, t]);
 
-  const startCall = useStartCall();
-
   return (
     <Stack direction="row" padding="16px" overflow="hidden">
       <Stack flex={1} justifyContent="center" whiteSpace="nowrap" overflow="hidden">
@@ -82,14 +89,8 @@
         </Typography>
       </Stack>
       <Stack direction="row" spacing="20px">
-        <StartAudioCallButton onClick={() => startCall(conversationId)} />
-        <StartVideoCallButton
-          onClick={() =>
-            startCall(conversationId, {
-              isVideoOn: true,
-            })
-          }
-        />
+        <StartAudioCallButton onClick={() => startCall({ conversationId, role: 'caller' })} />
+        <StartVideoCallButton onClick={() => startCall({ conversationId, role: 'caller', withVideoOn: true })} />
         <AddParticipantButton />
         <ShowOptionsMenuButton />
       </Stack>
diff --git a/client/src/contexts/CallManagerProvider.tsx b/client/src/contexts/CallManagerProvider.tsx
new file mode 100644
index 0000000..4261f07
--- /dev/null
+++ b/client/src/contexts/CallManagerProvider.tsx
@@ -0,0 +1,116 @@
+/*
+ * Copyright (C) 2022 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation; either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public
+ * License along with this program.  If not, see
+ * <https://www.gnu.org/licenses/>.
+ */
+import { CallBegin, WebSocketMessageType } from 'jami-web-common';
+import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { Conversation } from '../models/conversation';
+import { useConversationQuery } from '../services/conversationQueries';
+import { SetState, WithChildren } from '../utils/utils';
+import CallProvider, { CallRole } from './CallProvider';
+import WebRtcProvider from './WebRtcProvider';
+import { WebSocketContext } from './WebSocketProvider';
+
+type CallData = {
+  conversationId: string;
+  role: CallRole;
+  withVideoOn?: boolean;
+};
+
+type ICallManagerContext = {
+  callData: CallData | undefined;
+  callConversation: Conversation | undefined;
+
+  startCall: SetState<CallData | undefined>;
+  exitCall: () => void;
+};
+
+export const CallManagerContext = createContext<ICallManagerContext>(undefined!);
+CallManagerContext.displayName = 'CallManagerContext';
+
+export default ({ children }: WithChildren) => {
+  const [callData, setCallData] = useState<CallData>();
+  const webSocket = useContext(WebSocketContext);
+  const navigate = useNavigate();
+  const conversationId = callData?.conversationId;
+  const { conversation } = useConversationQuery(conversationId);
+
+  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(() => {
+    if (callData) {
+      // TODO: Currently, we simply do not bind the CallBegin listener if already in a call.
+      //       In the future, we should handle receiving a call while already in another.
+      return;
+    }
+    if (!webSocket) {
+      return;
+    }
+
+    const callBeginListener = ({ conversationId, withVideoOn }: CallBegin) => {
+      startCall({ conversationId: conversationId, role: 'receiver', withVideoOn });
+      navigate(`/conversation/${conversationId}`);
+    };
+
+    webSocket.bind(WebSocketMessageType.CallBegin, callBeginListener);
+
+    return () => {
+      webSocket.unbind(WebSocketMessageType.CallBegin, callBeginListener);
+    };
+  }, [webSocket, navigate, startCall, callData]);
+
+  return (
+    <CallManagerContext.Provider
+      value={{
+        startCall,
+        callData,
+        callConversation: conversation,
+        exitCall,
+      }}
+    >
+      <CallManagerProvider>{children}</CallManagerProvider>
+    </CallManagerContext.Provider>
+  );
+};
+
+const CallManagerProvider = ({ children }: WithChildren) => {
+  const { callData } = useContext(CallManagerContext);
+
+  if (!callData) {
+    return <>{children}</>;
+  }
+
+  return (
+    <WebRtcProvider>
+      <CallProvider>{children}</CallProvider>
+    </WebRtcProvider>
+  );
+};
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index 88951a9..b20d0e7 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -17,15 +17,13 @@
  */
 import { CallAction, CallBegin, WebSocketMessageType } from 'jami-web-common';
 import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
-import { Navigate, useNavigate } from 'react-router-dom';
+import { Navigate } from 'react-router-dom';
 
 import LoadingPage from '../components/Loading';
-import { useUrlParams } from '../hooks/useUrlParams';
-import CallPermissionDenied from '../pages/CallPermissionDenied';
-import { CallRouteParams } from '../router';
+import { Conversation } from '../models/conversation';
 import { callTimeoutMs } from '../utils/constants';
 import { AsyncSetState, SetState, WithChildren } from '../utils/utils';
-import { useConversationContext } from './ConversationProvider';
+import { CallManagerContext } from './CallManagerProvider';
 import { MediaDevicesInfo, MediaInputKind, WebRtcContext } from './WebRtcProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
@@ -113,21 +111,30 @@
 
 export default ({ children }: WithChildren) => {
   const webSocket = useContext(WebSocketContext);
+  const { callConversation, callData } = useContext(CallManagerContext);
 
-  if (!webSocket) {
+  if (!webSocket || !callConversation || !callData?.conversationId) {
     return <LoadingPage />;
   }
 
-  return <CallProvider webSocket={webSocket}>{children}</CallProvider>;
+  return (
+    <CallProvider webSocket={webSocket} conversation={callConversation} conversationId={callData?.conversationId}>
+      {children}
+    </CallProvider>
+  );
 };
 
 const CallProvider = ({
   children,
+  conversation,
+  conversationId,
   webSocket,
 }: WithChildren & {
   webSocket: IWebSocketContext;
+  conversation: Conversation;
+  conversationId: string;
 }) => {
-  const { state: routeState } = useUrlParams<CallRouteParams>();
+  const { callData, exitCall } = useContext(CallManagerContext);
   const {
     localStream,
     updateScreenShare,
@@ -137,8 +144,6 @@
     getMediaDevices,
     updateLocalStream,
   } = useContext(WebRtcContext);
-  const { conversationId, conversation } = useConversationContext();
-  const navigate = useNavigate();
 
   const [mediaDevices, setMediaDevices] = useState(defaultCallContext.mediaDevices);
   const [audioInputDeviceId, setAudioInputDeviceId] = useState<string>();
@@ -149,8 +154,8 @@
   const [videoStatus, setVideoStatus] = useState(VideoStatus.Off);
   const [isChatShown, setIsChatShown] = useState(false);
   const [isFullscreen, setIsFullscreen] = useState(false);
-  const [callStatus, setCallStatus] = useState(routeState?.callStatus);
-  const [callRole] = useState(routeState?.role);
+  const [callStatus, setCallStatus] = useState(CallStatus.Default);
+  const [callRole] = useState(callData?.role);
   const [callStartTime, setCallStartTime] = useState<number | undefined>(undefined);
 
   // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
@@ -243,7 +248,7 @@
 
   useEffect(() => {
     if (callRole === 'caller' && callStatus === CallStatus.Default) {
-      const withVideoOn = routeState?.isVideoOn ?? false;
+      const withVideoOn = callData?.withVideoOn ?? false;
       setCallStatus(CallStatus.Loading);
       updateLocalStream()
         .then(() => {
@@ -263,7 +268,7 @@
           setCallStatus(CallStatus.PermissionsDenied);
         });
     }
-  }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, routeState]);
+  }, [webSocket, updateLocalStream, callRole, callStatus, contactUri, conversationId, callData]);
 
   const acceptCall = useCallback(
     (withVideoOn: boolean) => {
@@ -319,9 +324,9 @@
     console.info('Sending CallEnd', callEnd);
     closeConnection();
     webSocket.send(WebSocketMessageType.CallEnd, callEnd);
-    navigate(`/conversation/${conversationId}`);
+    exitCall();
     // TODO: write in chat that the call ended
-  }, [webSocket, contactUri, conversationId, closeConnection, navigate]);
+  }, [webSocket, contactUri, conversationId, closeConnection, exitCall]);
 
   useEffect(() => {
     const callEndListener = (data: CallAction) => {
@@ -332,7 +337,7 @@
       }
 
       closeConnection();
-      navigate(`/conversation/${conversationId}`);
+      exitCall();
       // TODO: write in chat that the call ended
     };
 
@@ -340,7 +345,7 @@
     return () => {
       webSocket.unbind(WebSocketMessageType.CallEnd, callEndListener);
     };
-  }, [webSocket, navigate, conversationId, closeConnection]);
+  }, [webSocket, exitCall, conversationId, closeConnection]);
 
   useEffect(() => {
     if (
@@ -401,14 +406,7 @@
     };
   }, [updateLocalStream, audioInputDeviceId, audioOutputDeviceId, videoDeviceId]);
 
-  useEffect(() => {
-    navigate('.', {
-      replace: true,
-      state: {},
-    });
-  }, [navigate]);
-
-  if (!callRole || callStatus === undefined) {
+  if (!callData || !callRole) {
     console.error('Invalid route. Redirecting...');
     return <Navigate to={'/'} />;
   }
@@ -433,7 +431,7 @@
         endCall,
       }}
     >
-      {callStatus === CallStatus.PermissionsDenied ? <CallPermissionDenied /> : children}
+      {children}
     </CallContext.Provider>
   );
 };
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
index 8d5f7b5..e2aa4bc 100644
--- a/client/src/contexts/ConversationProvider.tsx
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -16,7 +16,7 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { ConversationView, WebSocketMessageType } from 'jami-web-common';
-import { useContext, useEffect, useState } from 'react';
+import { useContext, useEffect } from 'react';
 
 import LoadingPage from '../components/Loading';
 import { createOptionalContext } from '../hooks/createOptionalContext';
@@ -43,26 +43,8 @@
   } = useUrlParams<ConversationRouteParams>();
   const { accountId } = useAuthContext();
   const webSocket = useContext(WebSocketContext);
-  const [isLoading, setIsLoading] = useState(false);
-  const [isError, setIsError] = useState(false);
-  const [conversation, setConversation] = useState<Conversation | undefined>();
 
-  const conversationQuery = useConversationQuery(conversationId!);
-
-  useEffect(() => {
-    if (conversationQuery.isSuccess) {
-      const conversation = Conversation.fromInterface(conversationQuery.data);
-      setConversation(conversation);
-    }
-  }, [accountId, conversationQuery.isSuccess, conversationQuery.data]);
-
-  useEffect(() => {
-    setIsLoading(conversationQuery.isLoading);
-  }, [conversationQuery.isLoading]);
-
-  useEffect(() => {
-    setIsError(conversationQuery.isError);
-  }, [conversationQuery.isError]);
+  const { conversation, isLoading, isError } = useConversationQuery(conversationId!);
 
   useEffect(() => {
     if (!conversation || !conversationId || !webSocket) {
diff --git a/client/src/contexts/WebRtcProvider.tsx b/client/src/contexts/WebRtcProvider.tsx
index b7733bc..bb99717 100644
--- a/client/src/contexts/WebRtcProvider.tsx
+++ b/client/src/contexts/WebRtcProvider.tsx
@@ -20,9 +20,10 @@
 import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 import LoadingPage from '../components/Loading';
+import { Conversation } from '../models/conversation';
 import { WithChildren } from '../utils/utils';
 import { useAuthContext } from './AuthProvider';
-import { useConversationContext } from './ConversationProvider';
+import { CallManagerContext } from './CallManagerProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
 export type MediaDevicesInfo = Record<MediaDeviceKind, MediaDeviceInfo[]>;
@@ -61,6 +62,7 @@
   const { account } = useAuthContext();
   const [webRtcConnection, setWebRtcConnection] = useState<RTCPeerConnection | undefined>();
   const webSocket = useContext(WebSocketContext);
+  const { callConversation, callData } = useContext(CallManagerContext);
 
   useEffect(() => {
     if (!webRtcConnection && account) {
@@ -84,12 +86,17 @@
     }
   }, [account, webRtcConnection]);
 
-  if (!webRtcConnection || !webSocket) {
+  if (!webRtcConnection || !webSocket || !callConversation || !callData?.conversationId) {
     return <LoadingPage />;
   }
 
   return (
-    <WebRtcProvider webRtcConnection={webRtcConnection} webSocket={webSocket}>
+    <WebRtcProvider
+      webRtcConnection={webRtcConnection}
+      webSocket={webSocket}
+      conversation={callConversation}
+      conversationId={callData.conversationId}
+    >
       {children}
     </WebRtcProvider>
   );
@@ -97,13 +104,16 @@
 
 const WebRtcProvider = ({
   children,
+  conversation,
+  conversationId,
   webRtcConnection,
   webSocket,
 }: WithChildren & {
   webRtcConnection: RTCPeerConnection;
   webSocket: IWebSocketContext;
+  conversation: Conversation;
+  conversationId: string;
 }) => {
-  const { conversation, conversationId } = useConversationContext();
   const [localStream, setLocalStream] = useState<MediaStream>();
   const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
   const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
diff --git a/client/src/hooks/useStartCall.ts b/client/src/hooks/useStartCall.ts
deleted file mode 100644
index c20a2cc..0000000
--- a/client/src/hooks/useStartCall.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import { CallStatus } from '../contexts/CallProvider';
-import { CallRouteParams } from '../router';
-
-export const useStartCall = () => {
-  const navigate = useNavigate();
-
-  return useCallback(
-    (conversationId: string, state?: Partial<CallRouteParams['state']>) => {
-      navigate(`/conversation/${conversationId}/call`, {
-        state: {
-          callStatus: CallStatus.Default,
-          role: 'caller',
-          ...state,
-        },
-      });
-    },
-    [navigate]
-  );
-};
diff --git a/client/src/managers/NotificationManager.tsx b/client/src/managers/NotificationManager.tsx
deleted file mode 100644
index 907efb5..0000000
--- a/client/src/managers/NotificationManager.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright (C) 2022 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation; either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public
- * License along with this program.  If not, see
- * <https://www.gnu.org/licenses/>.
- */
-import { CallBegin, WebSocketMessageType } from 'jami-web-common';
-import { useContext, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-
-import { CallStatus } from '../contexts/CallProvider';
-import { WebSocketContext } from '../contexts/WebSocketProvider';
-import { WithChildren } from '../utils/utils';
-
-/**
- * Binds notification listeners to the WebSocket from a WebSocketContext.
- */
-export default ({ children }: WithChildren) => {
-  const webSocket = useContext(WebSocketContext);
-  const navigate = useNavigate();
-
-  useEffect(() => {
-    if (!webSocket) {
-      return;
-    }
-
-    const callBeginListener = (data: CallBegin) => {
-      console.info('Received event on CallBegin', data);
-      navigate(`/conversation/${data.conversationId}/call`, {
-        state: {
-          role: 'receiver',
-          callStatus: CallStatus.Ringing,
-          isVideoOn: data.withVideoOn,
-        },
-      });
-    };
-
-    webSocket.bind(WebSocketMessageType.CallBegin, callBeginListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.CallBegin, callBeginListener);
-    };
-  }, [webSocket, navigate]);
-
-  return <>{children}</>;
-};
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index 3d5b366..4feecc2 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -51,6 +51,7 @@
 import { WebRtcContext } from '../contexts/WebRtcProvider';
 import { VideoElementWithSinkId } from '../utils/utils';
 import { CallPending } from './CallPending';
+import CallPermissionDenied from './CallPermissionDenied';
 
 export default () => {
   const { callStatus, isChatShown, isFullscreen } = useContext(CallContext);
@@ -68,6 +69,9 @@
     }
   }, [isFullscreen]);
 
+  if (callStatus === CallStatus.PermissionsDenied) {
+    return <CallPermissionDenied />;
+  }
   if (callStatus !== CallStatus.InCall) {
     return <CallPending />;
   }
@@ -213,7 +217,7 @@
 const CallInterfaceInformation = () => {
   const { callStartTime } = useContext(CallContext);
   const { conversation } = useConversationContext();
-  const [elapsedTime, setElapsedTime] = useState(0);
+  const [elapsedTime, setElapsedTime] = useState(callStartTime ? (Date.now() - callStartTime) / 1000 : 0);
   const memberName = useMemo(() => conversation.getFirstMember().contact.registeredName, [conversation]);
 
   useEffect(() => {
diff --git a/client/src/pages/CallPending.tsx b/client/src/pages/CallPending.tsx
index 54fb916..ea2017d 100644
--- a/client/src/pages/CallPending.tsx
+++ b/client/src/pages/CallPending.tsx
@@ -189,6 +189,7 @@
 
   switch (callStatus) {
     case CallStatus.Ringing:
+    case CallStatus.Default:
       title = t('incoming_call', {
         context: state?.isVideoOn ? 'video' : 'audio',
         member0: memberName,
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 7bbddd4..13b5a69 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -21,15 +21,12 @@
 import ContactList from './components/ContactList';
 import ConversationView from './components/ConversationView';
 import AuthProvider from './contexts/AuthProvider';
-import CallProvider, { CallRole, CallStatus } from './contexts/CallProvider';
+import CallManagerProvider from './contexts/CallManagerProvider';
 import ConversationProvider from './contexts/ConversationProvider';
 import MessengerProvider from './contexts/MessengerProvider';
-import WebRtcProvider from './contexts/WebRtcProvider';
 import WebSocketProvider from './contexts/WebSocketProvider';
 import { RouteParams } from './hooks/useUrlParams';
-import NotificationManager from './managers/NotificationManager';
 import AccountSettings from './pages/AccountSettings';
-import CallInterface from './pages/CallInterface';
 import GeneralSettings from './pages/GeneralSettings';
 import Messenger from './pages/Messenger';
 import Setup from './pages/Setup';
@@ -39,16 +36,6 @@
 
 export type ConversationRouteParams = RouteParams<{ conversationId?: string }, Record<string, never>>;
 
-export type CallRouteParams = RouteParams<
-  { conversationId?: string },
-  Record<string, never>,
-  {
-    role: CallRole;
-    isVideoOn?: boolean;
-    callStatus: CallStatus;
-  }
->;
-
 export const router = createBrowserRouter(
   createRoutesFromElements(
     <Route path="/" element={<App />} loader={appLoader}>
@@ -60,9 +47,9 @@
         element={
           <AuthProvider>
             <WebSocketProvider>
-              <NotificationManager>
+              <CallManagerProvider>
                 <Outlet />
-              </NotificationManager>
+              </CallManagerProvider>
             </WebSocketProvider>
           </AuthProvider>
         }
@@ -81,23 +68,11 @@
             element={
               <Messenger>
                 <ConversationProvider>
-                  <Outlet />
+                  <ConversationView />
                 </ConversationProvider>
               </Messenger>
             }
-          >
-            <Route index element={<ConversationView />} />
-            <Route
-              path="call"
-              element={
-                <WebRtcProvider>
-                  <CallProvider>
-                    <CallInterface />
-                  </CallProvider>
-                </WebRtcProvider>
-              }
-            />
-          </Route>
+          />
         </Route>
         <Route path="settings-account" element={<AccountSettings />} />
         <Route path="settings-general" element={<GeneralSettings />} />
diff --git a/client/src/services/conversationQueries.ts b/client/src/services/conversationQueries.ts
index 44c0aae..94946ad 100644
--- a/client/src/services/conversationQueries.ts
+++ b/client/src/services/conversationQueries.ts
@@ -17,12 +17,14 @@
  */
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { IConversation, Message } from 'jami-web-common';
+import { useMemo } from 'react';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { Conversation } from '../models/conversation';
 
-export const useConversationQuery = (conversationId: string) => {
+export const useConversationQuery = (conversationId?: string) => {
   const { axiosInstance } = useAuthContext();
-  return useQuery(
+  const conversationQuery = useQuery(
     ['conversation', conversationId],
     async () => {
       const { data } = await axiosInstance.get<IConversation>(`/conversations/${conversationId}`);
@@ -32,6 +34,17 @@
       enabled: !!conversationId,
     }
   );
+
+  const conversation = useMemo(() => {
+    if (conversationQuery.isSuccess) {
+      return Conversation.fromInterface(conversationQuery.data);
+    }
+  }, [conversationQuery.isSuccess, conversationQuery.data]);
+
+  return {
+    conversation,
+    ...conversationQuery,
+  };
 };
 
 export const useMessagesQuery = (conversationId: string) => {