Implement proper WebRTC call management

This is a big CR that implements the whole proper call logic.

-- Other Contributors --

This CR was created in pair programming with:

-  Charlie Duquette <charlie_duquette@hotmail.fr>

-- New files --

- `ConversationProvider`: Provides the conversation object to its children. Also contains the function to begin a call (first step in the flow) which sends the `BeginCall` event.
- `CallProvider`: Contains the call logic that was previously in WebRTCProvider. From now on, WebRTCProvider contains only the WebRTC logic, while CallProvider
  contains everything related to the call (get the media devices, start/accept calls...)
- `NotificationManager`: Wrapper component to bind the WebSocket CallBegin event listener. That listener will fire when a `BeginCall` event is received and will then redirect the user to the call receiving page.

-- New routes --

When a `conversationId` is included in the URL, all pages are inside a `<ConversationProvider />`.

When starting a call, the caller is redirected to:

> http://localhost:3000/conversation/:conversationId/call?role=caller

When receiving a call, the receiver is redirected to:

> http://localhost:3000/conversation/:conversationId/call?role=receiver&hostId={HOST_ID}

When the user is in a `.../call` route, the `WebRTCContext` and
`CallContext` are provided

The `hostId` is the account URI of the caller. It's used when the receiver wants to send their answer back to the caller.

```
/
|-- login: <Welcome />
|-- settings: <AccountSettings />
|-- ...
`-- conversation: <Messenger />
    |-- add-contact/:contactId
    `-- :conversationId: <ConversationProvider />
        |-- /: <ConversationView />
        `-- /call: <WebRTCProvider>
                     <CallProvider>
                       <CallInterface />
                     </CallProvider>
                    </WebRTCProvider>
```

-- Call flow --

1. Caller:

- Clicks "Call" button
- Sends `BeginCall` event
- Redirects to call page `/call?role=caller`
- Sets `callStatus` to "Ringing"

2. Receiver:

- Receieves `BeginCall` event
- The callback in `NotificationManager` is called
- Redirects to the call receiving page `/conversation/{CONVERSATION_ID}/call?role=receiver`

3. Receiver:

- Clicks the "Answer call" button
- Sends a `CallAccept` event
- Sets `callStatus` to "Connecting"

4. Caller:

- Receives `CallAccept` event
- The callback in `CallProvider` is called
- Sets `callStatus` to "Connecting"
- Creates WebRTC Offer
- Sends `WebRTCOffer` event containing the offer SDP

5. Receiver:

- Receives `WebRTCOffer` event
- The callback in `WebRTCProvider` is called
- Sets WebRTC remote description.
- WebRTC `icecandidate` event fired. Sends `IceCandidate` WebSocket event
- Creates WebRTC answer
- Sends `WebRTCAnswer` event
- Sets WebRTC local description
- Sets connected status to true. Call page now shows the call interface

6. Caller:

- Receives `WebRTCAnswer` event
- Sets WebRTC local description
- Sets WebRTC remote description
- WebRTC `icecandidate` event fired. Sends `IceCandidate` WebSocket event
- Sets connected status to true. Call page now shows the call interface

-- Misc Changes --

- Improve CallPending and CallInterface UI
- Move `useUrlParams` hook from the (now deleted) `client/src/utils/hooks.ts` file to `client/src/hooks/useUrlParams.ts`
- Disable StrictMode. This was causing issues, because some event would be sent twice. There is a TODO comment to fix the problem and reenable it.
- Improvements in server `webrtc-handler.ts`. This is still a WIP
- Rename dash-case client files to camelCase

GitLab: #70
Change-Id: I6c75f6b867e8acb9ccaaa118b0123bba30431f78
diff --git a/client/src/contexts/AuthProvider.tsx b/client/src/contexts/AuthProvider.tsx
index 440c4dc..ced9a85 100644
--- a/client/src/contexts/AuthProvider.tsx
+++ b/client/src/contexts/AuthProvider.tsx
@@ -28,6 +28,7 @@
 interface IAuthContext {
   token: string;
   account: Account;
+  accountId: string;
   logout: () => void;
   axiosInstance: AxiosInstance;
 }
@@ -100,6 +101,7 @@
         token,
         logout,
         account,
+        accountId: account.getId(),
         axiosInstance,
       }}
     >
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
new file mode 100644
index 0000000..0cc667a
--- /dev/null
+++ b/client/src/contexts/CallProvider.tsx
@@ -0,0 +1,249 @@
+/*
+ * 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 { AccountTextMessage, WebSocketMessageType } from 'jami-web-common';
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { Navigate } from 'react-router-dom';
+
+import { useUrlParams } from '../hooks/useUrlParams';
+import { CallRouteParams } from '../router';
+import { WithChildren } from '../utils/utils';
+import { useAuthContext } from './AuthProvider';
+import { ConversationContext } from './ConversationProvider';
+import { WebRTCContext } from './WebRTCProvider';
+import { WebSocketContext } from './WebSocketProvider';
+
+export type CallRole = 'caller' | 'receiver';
+
+export enum CallStatus {
+  Ringing,
+  Connecting,
+  InCall,
+}
+
+export interface ICallContext {
+  mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
+
+  localStream: MediaStream | undefined;
+  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
+
+  isAudioOn: boolean;
+  setAudioStatus: (isOn: boolean) => void;
+  isVideoOn: boolean;
+  setVideoStatus: (isOn: boolean) => void;
+  callRole: CallRole;
+  callStatus: CallStatus;
+
+  acceptCall: () => void;
+}
+
+const defaultCallContext: ICallContext = {
+  mediaDevices: {
+    audioinput: [],
+    audiooutput: [],
+    videoinput: [],
+  },
+
+  localStream: undefined,
+  remoteStream: undefined,
+
+  isAudioOn: false,
+  setAudioStatus: () => {},
+  isVideoOn: false,
+  setVideoStatus: () => {},
+  callRole: 'caller',
+  callStatus: CallStatus.Ringing,
+
+  acceptCall: () => {},
+};
+
+export const CallContext = createContext<ICallContext>(defaultCallContext);
+
+export default ({ children }: WithChildren) => {
+  const {
+    queryParams: { role: callRole },
+  } = useUrlParams<CallRouteParams>();
+  const { account } = useAuthContext();
+  const webSocket = useContext(WebSocketContext);
+  const { webRTCConnection, remoteStreams, sendWebRTCOffer, isConnected } = useContext(WebRTCContext);
+  const { conversation } = useContext(ConversationContext);
+
+  const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
+    defaultCallContext.mediaDevices
+  );
+  const [localStream, setLocalStream] = useState<MediaStream>();
+
+  const [isAudioOn, setIsAudioOn] = useState(false);
+  const [isVideoOn, setIsVideoOn] = useState(false);
+  const [callStatus, setCallStatus] = useState(CallStatus.Ringing);
+
+  // TODO: This logic will have to change to support multiple people in a call
+  const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
+
+  useEffect(() => {
+    // TODO: Wait until status is `InCall` before getting devices
+    navigator.mediaDevices.enumerateDevices().then((devices) => {
+      const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
+        audioinput: [],
+        audiooutput: [],
+        videoinput: [],
+      };
+
+      for (const device of devices) {
+        newMediaDevices[device.kind].push(device);
+      }
+
+      setMediaDevices(newMediaDevices);
+    });
+  }, []);
+
+  useEffect(() => {
+    // TODO: Only ask media permission once the call has been accepted
+    try {
+      // TODO: When toggling mute on/off, the camera flickers
+      // https://git.jami.net/savoirfairelinux/jami-web/-/issues/90
+      navigator.mediaDevices
+        .getUserMedia({
+          audio: true, // TODO: Set both to false by default
+          video: true,
+        })
+        .then((stream) => {
+          for (const track of stream.getTracks()) {
+            // TODO: Set default from isVideoOn and isMicOn values
+            track.enabled = false;
+          }
+          setLocalStream(stream);
+        });
+    } catch (e) {
+      // TODO: Better handle user denial
+      console.error('Could not get media devices:', e);
+    }
+  }, [setLocalStream]);
+
+  useEffect(() => {
+    if (localStream && webRTCConnection) {
+      for (const track of localStream.getTracks()) {
+        webRTCConnection.addTrack(track, localStream);
+      }
+    }
+  }, [localStream, webRTCConnection]);
+
+  const setAudioStatus = useCallback(
+    (isOn: boolean) => {
+      if (!localStream) {
+        return;
+      }
+
+      for (const track of localStream.getAudioTracks()) {
+        track.enabled = isOn;
+      }
+
+      setIsAudioOn(isOn);
+    },
+    [localStream]
+  );
+
+  const setVideoStatus = useCallback(
+    (isOn: boolean) => {
+      if (!localStream) {
+        return;
+      }
+
+      for (const track of localStream.getVideoTracks()) {
+        track.enabled = isOn;
+      }
+
+      setIsVideoOn(isOn);
+    },
+    [localStream]
+  );
+
+  useEffect(() => {
+    if (!webSocket || !webRTCConnection) {
+      return;
+    }
+
+    if (callRole === 'caller' && callStatus === CallStatus.Ringing) {
+      const callAcceptListener = (data: AccountTextMessage<undefined>) => {
+        console.info('Received event on CallAccept', data);
+        setCallStatus(CallStatus.Connecting);
+
+        webRTCConnection
+          .createOffer({
+            offerToReceiveAudio: true,
+            offerToReceiveVideo: true,
+          })
+          .then((offerSDP) => {
+            sendWebRTCOffer(offerSDP);
+          });
+      };
+
+      webSocket.bind(WebSocketMessageType.CallAccept, callAcceptListener);
+
+      return () => {
+        webSocket.unbind(WebSocketMessageType.CallAccept, callAcceptListener);
+      };
+    }
+  }, [callRole, webSocket, webRTCConnection, sendWebRTCOffer, callStatus]);
+
+  useEffect(() => {
+    if (callStatus === CallStatus.Connecting && isConnected) {
+      console.info('Changing call status to InCall');
+      setCallStatus(CallStatus.InCall);
+    }
+  }, [isConnected, callStatus]);
+
+  const acceptCall = useCallback(() => {
+    if (!webSocket) {
+      throw new Error('Could not accept call');
+    }
+
+    const callAccept = {
+      from: account.getId(),
+      to: contactUri,
+      message: undefined,
+    };
+
+    console.info('Sending CallAccept', callAccept);
+    webSocket.send(WebSocketMessageType.CallAccept, callAccept);
+    setCallStatus(CallStatus.Connecting);
+  }, [webSocket, account, contactUri]);
+
+  if (!callRole) {
+    console.error('Call role not defined. Redirecting...');
+    return <Navigate to={'/'} />;
+  }
+
+  return (
+    <CallContext.Provider
+      value={{
+        mediaDevices,
+        localStream,
+        remoteStream: remoteStreams?.at(-1),
+        isAudioOn,
+        setAudioStatus,
+        isVideoOn,
+        setVideoStatus,
+        callRole,
+        callStatus,
+        acceptCall,
+      }}
+    >
+      {children}
+    </CallContext.Provider>
+  );
+};
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
new file mode 100644
index 0000000..89fb450
--- /dev/null
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -0,0 +1,114 @@
+/*
+ * 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 { Conversation, WebSocketMessageType } from 'jami-web-common';
+import { createContext, useCallback, useContext, useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import LoadingPage from '../components/Loading';
+import { useUrlParams } from '../hooks/useUrlParams';
+import { ConversationRouteParams } from '../router';
+import { useConversationQuery } from '../services/Conversation';
+import { WithChildren } from '../utils/utils';
+import { useAuthContext } from './AuthProvider';
+import { WebSocketContext } from './WebSocketProvider';
+
+interface IConversationProvider {
+  conversationId: string;
+  conversation: Conversation;
+
+  beginCall: () => void;
+}
+
+export const ConversationContext = createContext<IConversationProvider>(undefined!);
+
+export default ({ children }: WithChildren) => {
+  const {
+    urlParams: { conversationId },
+  } = useUrlParams<ConversationRouteParams>();
+  const { account, accountId } = useAuthContext();
+  const webSocket = useContext(WebSocketContext);
+  const [isLoading, setIsLoading] = useState(false);
+  const [isError, setIsError] = useState(false);
+  const [conversation, setConversation] = useState<Conversation | undefined>();
+  const navigate = useNavigate();
+
+  const conversationQuery = useConversationQuery(conversationId);
+
+  useEffect(() => {
+    if (conversationQuery.isSuccess) {
+      const conversation = Conversation.from(accountId, conversationQuery.data);
+      setConversation(conversation);
+    }
+  }, [accountId, conversationQuery.isSuccess, conversationQuery.data]);
+
+  useEffect(() => {
+    setIsLoading(conversationQuery.isLoading);
+  }, [conversationQuery.isLoading]);
+
+  useEffect(() => {
+    setIsError(conversationQuery.isError);
+  }, [conversationQuery.isError]);
+
+  const beginCall = useCallback(() => {
+    if (!webSocket || !conversation) {
+      throw new Error('Could not begin call');
+    }
+
+    // TODO: Could we move this logic to the server? The client could make a single request with the conversationId, and the server is tasked with sending all the individual requests to the members of the conversation
+    for (const member of conversation.getMembers()) {
+      const callBegin = {
+        from: account.getId(),
+        to: member.contact.getUri(),
+        message: {
+          conversationId,
+        },
+      };
+
+      console.info('Sending CallBegin', callBegin);
+      webSocket.send(WebSocketMessageType.CallBegin, callBegin);
+    }
+
+    navigate(`/conversation/${conversationId}/call?role=caller`);
+  }, [conversationId, webSocket, conversation, account, navigate]);
+
+  useEffect(() => {
+    if (!conversation || !webSocket) {
+      return;
+    }
+    webSocket.send(WebSocketMessageType.ConversationView, { accountId, conversationId });
+  }, [accountId, conversation, conversationId, webSocket]);
+
+  if (isLoading) {
+    return <LoadingPage />;
+  }
+  if (isError || !conversation || !conversationId) {
+    return <div>Error loading conversation: {conversationId}</div>;
+  }
+
+  return (
+    <ConversationContext.Provider
+      value={{
+        conversationId,
+        conversation,
+        beginCall,
+      }}
+    >
+      {children}
+    </ConversationContext.Provider>
+  );
+};
diff --git a/client/src/contexts/WebRTCProvider.tsx b/client/src/contexts/WebRTCProvider.tsx
index 7b32853..04cc61a 100644
--- a/client/src/contexts/WebRTCProvider.tsx
+++ b/client/src/contexts/WebRTCProvider.tsx
@@ -16,90 +16,42 @@
  * <https://www.gnu.org/licenses/>.
  */
 
-import { AccountTextMessage, WebRTCIceCandidate, WebRTCSDP, WebSocketMessageType } from 'jami-web-common';
-import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
+import { AccountTextMessage, WebRTCIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
+import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 import { WithChildren } from '../utils/utils';
 import { useAuthContext } from './AuthProvider';
+import { ConversationContext } from './ConversationProvider';
 import { WebSocketContext } from './WebSocketProvider';
 
 interface IWebRTCContext {
-  localVideoRef: React.RefObject<HTMLVideoElement> | null;
-  remoteVideoRef: React.RefObject<HTMLVideoElement> | null;
+  isConnected: boolean;
 
-  mediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]>;
+  remoteStreams: readonly MediaStream[] | undefined;
+  webRTCConnection: RTCPeerConnection | undefined;
 
-  contactId: string;
-
-  isAudioOn: boolean;
-  setAudioStatus: (isOn: boolean) => void;
-  isVideoOn: boolean;
-  setVideoStatus: (isOn: boolean) => void;
-  sendWebRTCOffer: () => void;
+  sendWebRTCOffer: (sdp: RTCSessionDescriptionInit) => Promise<void>;
 }
 
 const defaultWebRTCContext: IWebRTCContext = {
-  localVideoRef: null,
-  remoteVideoRef: null,
-  mediaDevices: {
-    audioinput: [],
-    audiooutput: [],
-    videoinput: [],
-  },
-
-  contactId: '',
-
-  isAudioOn: false,
-  setAudioStatus: () => {},
-  isVideoOn: false,
-  setVideoStatus: () => {},
-
-  sendWebRTCOffer: () => {},
+  isConnected: false,
+  remoteStreams: undefined,
+  webRTCConnection: undefined,
+  sendWebRTCOffer: async () => {},
 };
 
 export const WebRTCContext = createContext<IWebRTCContext>(defaultWebRTCContext);
 
-type WebRTCProviderProps = WithChildren & {
-  contactId: string;
-  isAudioOn?: boolean;
-  isVideoOn?: boolean;
-};
-
-// TODO: This is a WIP. The calling logic will be improved in other CRs
-export default ({
-  children,
-  isAudioOn: _isAudioOn = defaultWebRTCContext.isAudioOn,
-  isVideoOn: _isVideoOn = defaultWebRTCContext.isVideoOn,
-  contactId: _contactId = defaultWebRTCContext.contactId,
-}: WebRTCProviderProps) => {
-  const [isAudioOn, setIsAudioOn] = useState(_isAudioOn);
-  const [isVideoOn, setIsVideoOn] = useState(_isVideoOn);
-  const localVideoRef = useRef<HTMLVideoElement>(null);
-  const remoteVideoRef = useRef<HTMLVideoElement>(null);
-  const { account } = useAuthContext();
-  const contactId = _contactId;
-  const [webRTCConnection, setWebRTCConnection] = useState<RTCPeerConnection | undefined>();
-  const localStreamRef = useRef<MediaStream>();
+export default ({ children }: WithChildren) => {
+  const { accountId } = useAuthContext();
   const webSocket = useContext(WebSocketContext);
-  const [mediaDevices, setMediaDevices] = useState<Record<MediaDeviceKind, MediaDeviceInfo[]>>(
-    defaultWebRTCContext.mediaDevices
-  );
+  const { conversation } = useContext(ConversationContext);
+  const [webRTCConnection, setWebRTCConnection] = useState<RTCPeerConnection | undefined>();
+  const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
+  const [isConnected, setIsConnected] = useState(false);
 
-  useEffect(() => {
-    navigator.mediaDevices.enumerateDevices().then((devices) => {
-      const newMediaDevices: Record<MediaDeviceKind, MediaDeviceInfo[]> = {
-        audioinput: [],
-        audiooutput: [],
-        videoinput: [],
-      };
-
-      for (const device of devices) {
-        newMediaDevices[device.kind].push(device);
-      }
-
-      setMediaDevices(newMediaDevices);
-    });
-  }, []);
+  // TODO: This logic will have to change to support multiple people in a call
+  const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
 
   useEffect(() => {
     if (!webRTCConnection) {
@@ -109,59 +61,112 @@
     }
   }, [webRTCConnection]);
 
+  const sendWebRTCOffer = useCallback(
+    async (sdp: RTCSessionDescriptionInit) => {
+      if (!webRTCConnection || !webSocket) {
+        throw new Error('Could not send WebRTC offer');
+      }
+      const webRTCOffer: AccountTextMessage<WebRtcSdp> = {
+        from: accountId,
+        to: contactUri,
+        message: {
+          sdp,
+        },
+      };
+
+      console.info('Sending WebRTCOffer', webRTCOffer);
+      webSocket.send(WebSocketMessageType.WebRTCOffer, webRTCOffer);
+      await webRTCConnection.setLocalDescription(new RTCSessionDescription(sdp));
+    },
+    [accountId, webRTCConnection, webSocket, contactUri]
+  );
+
+  const sendWebRTCAnswer = useCallback(
+    (sdp: RTCSessionDescriptionInit) => {
+      if (!webRTCConnection || !webSocket) {
+        throw new Error('Could not send WebRTC answer');
+      }
+
+      const webRTCAnswer: AccountTextMessage<WebRtcSdp> = {
+        from: accountId,
+        to: contactUri,
+        message: {
+          sdp,
+        },
+      };
+
+      console.info('Sending WebRTCAnswer', webRTCAnswer);
+      webSocket.send(WebSocketMessageType.WebRTCAnswer, webRTCAnswer);
+    },
+    [accountId, contactUri, webRTCConnection, webSocket]
+  );
+
   useEffect(() => {
-    if (!webRTCConnection) {
+    if (!webSocket || !webRTCConnection) {
       return;
     }
 
-    if (isVideoOn || isAudioOn) {
-      try {
-        // TODO: When toggling mute on/off, the camera flickers
-        // https://git.jami.net/savoirfairelinux/jami-web/-/issues/90
-        navigator.mediaDevices
-          .getUserMedia({
-            audio: true,
-            video: true,
-          })
-          .then((stream) => {
-            if (localVideoRef.current) {
-              localVideoRef.current.srcObject = stream;
-            }
+    const webRTCOfferListener = async (data: AccountTextMessage<WebRtcSdp>) => {
+      console.info('Received event on WebRTCOffer', data);
+      await webRTCConnection.setRemoteDescription(new RTCSessionDescription(data.message.sdp));
 
-            stream.getTracks().forEach((track) => {
-              if (track.kind === 'audio') {
-                track.enabled = isAudioOn;
-              } else if (track.kind === 'video') {
-                track.enabled = isVideoOn;
-              }
-              webRTCConnection.addTrack(track, stream);
-            });
-            localStreamRef.current = stream;
-          });
-      } catch (e) {
-        console.error('Could not get media devices: ', e);
-      }
+      const sdp = await webRTCConnection.createAnswer({
+        offerToReceiveAudio: true,
+        offerToReceiveVideo: true,
+      });
+      sendWebRTCAnswer(sdp);
+      await webRTCConnection.setLocalDescription(new RTCSessionDescription(sdp));
+      setIsConnected(true);
+    };
+
+    const webRTCAnswerListener = async (data: AccountTextMessage<WebRtcSdp>) => {
+      console.info('Received event on WebRTCAnswer', data);
+      await webRTCConnection.setRemoteDescription(new RTCSessionDescription(data.message.sdp));
+      setIsConnected(true);
+    };
+
+    const webRTCIceCandidateListener = async (data: AccountTextMessage<WebRTCIceCandidate>) => {
+      console.info('Received event on WebRTCIceCandidate', data);
+      await webRTCConnection.addIceCandidate(data.message.candidate);
+    };
+
+    webSocket.bind(WebSocketMessageType.WebRTCOffer, webRTCOfferListener);
+    webSocket.bind(WebSocketMessageType.WebRTCAnswer, webRTCAnswerListener);
+    webSocket.bind(WebSocketMessageType.IceCandidate, webRTCIceCandidateListener);
+
+    return () => {
+      webSocket.unbind(WebSocketMessageType.WebRTCOffer, webRTCOfferListener);
+      webSocket.unbind(WebSocketMessageType.WebRTCAnswer, webRTCAnswerListener);
+      webSocket.unbind(WebSocketMessageType.IceCandidate, webRTCIceCandidateListener);
+    };
+  }, [webSocket, webRTCConnection, sendWebRTCAnswer]);
+
+  useEffect(() => {
+    if (!webRTCConnection || !webSocket) {
+      return;
     }
 
     const icecandidateEventListener = (event: RTCPeerConnectionIceEvent) => {
-      if (event.candidate && webSocket) {
-        console.log('webRTCConnection : onicecandidate');
-        webSocket.send(WebSocketMessageType.IceCandidate, {
-          from: account.getId(),
-          to: contactId,
+      console.info('Received WebRTC event on icecandidate', event);
+      if (!contactUri) {
+        throw new Error('Could not handle WebRTC event on icecandidate: contactUri is not defined');
+      }
+      if (event.candidate) {
+        const iceCandidateMessageData: AccountTextMessage<WebRTCIceCandidate> = {
+          from: accountId,
+          to: contactUri,
           message: {
             candidate: event.candidate,
           },
-        });
+        };
+
+        console.info('Sending IceCandidate', iceCandidateMessageData);
+        webSocket.send(WebSocketMessageType.IceCandidate, iceCandidateMessageData);
       }
     };
-
     const trackEventListener = (event: RTCTrackEvent) => {
-      console.log('remote TrackEvent');
-      if (remoteVideoRef.current) {
-        remoteVideoRef.current.srcObject = event.streams[0];
-        console.log('webRTCConnection : add remotetrack success');
-      }
+      console.info('Received WebRTC event on track', event);
+      setRemoteStreams(event.streams);
     };
 
     webRTCConnection.addEventListener('icecandidate', icecandidateEventListener);
@@ -171,94 +176,14 @@
       webRTCConnection.removeEventListener('icecandidate', icecandidateEventListener);
       webRTCConnection.removeEventListener('track', trackEventListener);
     };
-  }, [webRTCConnection, isVideoOn, isAudioOn, webSocket, contactId, account]);
-
-  useEffect(() => {
-    if (!webRTCConnection || !webSocket) {
-      return;
-    }
-
-    const webRTCOfferListener = async (data: AccountTextMessage<WebRTCSDP>) => {
-      if (webRTCConnection) {
-        await webRTCConnection.setRemoteDescription(new RTCSessionDescription(data.message.sdp));
-        const mySdp = await webRTCConnection.createAnswer({
-          offerToReceiveAudio: true,
-          offerToReceiveVideo: true,
-        });
-        await webRTCConnection.setLocalDescription(new RTCSessionDescription(mySdp));
-        webSocket.send(WebSocketMessageType.WebRTCAnswer, {
-          from: account.getId(),
-          to: contactId,
-          message: {
-            sdp: mySdp,
-          },
-        });
-      }
-    };
-
-    const webRTCAnswerListener = async (data: AccountTextMessage<WebRTCSDP>) => {
-      await webRTCConnection.setRemoteDescription(new RTCSessionDescription(data.message.sdp));
-      console.log('get answer');
-    };
-
-    const iceCandidateListener = async (data: AccountTextMessage<WebRTCIceCandidate>) => {
-      await webRTCConnection.addIceCandidate(new RTCIceCandidate(data.message.candidate));
-      console.log('webRTCConnection : candidate add success');
-    };
-
-    webSocket.bind(WebSocketMessageType.WebRTCOffer, webRTCOfferListener);
-    webSocket.bind(WebSocketMessageType.WebRTCAnswer, webRTCAnswerListener);
-    webSocket.bind(WebSocketMessageType.IceCandidate, iceCandidateListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.WebRTCOffer, webRTCOfferListener);
-      webSocket.unbind(WebSocketMessageType.WebRTCAnswer, webRTCAnswerListener);
-      webSocket.unbind(WebSocketMessageType.IceCandidate, iceCandidateListener);
-    };
-  }, [account, contactId, webSocket, webRTCConnection]);
-
-  const setAudioStatus = useCallback((isOn: boolean) => {
-    setIsAudioOn(isOn);
-    localStreamRef.current?.getAudioTracks().forEach((track) => {
-      track.enabled = isOn;
-    });
-  }, []);
-
-  const setVideoStatus = useCallback((isOn: boolean) => {
-    setIsVideoOn(isOn);
-    localStreamRef.current?.getVideoTracks().forEach((track) => {
-      track.enabled = isOn;
-    });
-  }, []);
-
-  const sendWebRTCOffer = useCallback(async () => {
-    if (webRTCConnection && webSocket) {
-      const sdp = await webRTCConnection.createOffer({
-        offerToReceiveAudio: true,
-        offerToReceiveVideo: true,
-      });
-      webSocket.send(WebSocketMessageType.WebRTCOffer, {
-        from: account.getId(),
-        to: contactId,
-        message: {
-          sdp,
-        },
-      });
-      await webRTCConnection.setLocalDescription(new RTCSessionDescription(sdp));
-    }
-  }, [account, contactId, webSocket, webRTCConnection]);
+  }, [accountId, webRTCConnection, webSocket, contactUri]);
 
   return (
     <WebRTCContext.Provider
       value={{
-        localVideoRef,
-        remoteVideoRef,
-        mediaDevices,
-        contactId,
-        isAudioOn,
-        setAudioStatus,
-        isVideoOn,
-        setVideoStatus,
+        isConnected,
+        remoteStreams,
+        webRTCConnection,
         sendWebRTCOffer,
       }}
     >
diff --git a/client/src/contexts/WebSocketProvider.tsx b/client/src/contexts/WebSocketProvider.tsx
index b90f609..917dc22 100644
--- a/client/src/contexts/WebSocketProvider.tsx
+++ b/client/src/contexts/WebSocketProvider.tsx
@@ -131,19 +131,13 @@
 
   useEffect(connect, [connect]);
 
-  return (
-    <WebSocketContext.Provider
-      value={
-        isConnected
-          ? {
-              bind,
-              unbind,
-              send,
-            }
-          : undefined
+  const value: IWebSocketContext | undefined = isConnected
+    ? {
+        bind,
+        unbind,
+        send,
       }
-    >
-      {children}
-    </WebSocketContext.Provider>
-  );
+    : undefined;
+
+  return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>;
 };