Move WebRtc logic to WebRtcManager

- This is intended to reduce the quantity of useEffect, which causes "cascading" updates. The end goal is to reintroduce StrictMode (soon), and hopefully to make the code easier to understand.
- Some logic to support calls with more than one peer has been added. This is intended to test the new architecture will be scalable with React hooks.

Change-Id: Id76c4061b06a759f55957a48a1283d46afc3f73a
diff --git a/client/src/contexts/WebRtcProvider.tsx b/client/src/contexts/WebRtcProvider.tsx
index e35df0c..f6bc81e 100644
--- a/client/src/contexts/WebRtcProvider.tsx
+++ b/client/src/contexts/WebRtcProvider.tsx
@@ -16,14 +16,14 @@
  * <https://www.gnu.org/licenses/>.
  */
 
-import { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
 import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 import { createOptionalContext } from '../hooks/createOptionalContext';
 import { ConversationMember } from '../models/conversation-member';
 import { WithChildren } from '../utils/utils';
+import { useWebRtcManager } from '../webrtc/WebRtcManager';
 import { useAuthContext } from './AuthProvider';
-import { CallManagerContext } from './CallManagerProvider';
+import { CallData, CallManagerContext } from './CallManagerProvider';
 import ConditionalContextProvider from './ConditionalContextProvider';
 import { IWebSocketContext, WebSocketContext } from './WebSocketProvider';
 
@@ -36,7 +36,7 @@
 
   localStream: MediaStream | undefined;
   screenShareLocalStream: MediaStream | undefined;
-  remoteStreams: readonly MediaStream[] | undefined;
+  remoteStreams: readonly MediaStream[];
   getMediaDevices: () => Promise<MediaDevicesInfo>;
   updateLocalStream: (mediaDeviceIds?: MediaInputIds) => Promise<void>;
   updateScreenShare: (active: boolean) => Promise<MediaStream | undefined>;
@@ -49,47 +49,17 @@
 export const useWebRtcContext = optionalWebRtcContext.useOptionalContext;
 
 export default ({ children }: WithChildren) => {
-  const { account } = useAuthContext();
-  const [webRtcConnection, setWebRtcConnection] = useState<RTCPeerConnection | undefined>();
   const webSocket = useContext(WebSocketContext);
   const { callConversationInfos, callMembers, callData } = useContext(CallManagerContext);
 
-  useEffect(() => {
-    if (webRtcConnection && !callData) {
-      setWebRtcConnection(undefined);
-      return;
-    }
-
-    if (!webRtcConnection && account && callData) {
-      const iceServers: RTCIceServer[] = [];
-
-      if (account.details['TURN.enable'] === 'true') {
-        iceServers.push({
-          urls: 'turn:' + account.details['TURN.server'],
-          username: account.details['TURN.username'],
-          credential: account.details['TURN.password'],
-        });
-      }
-
-      if (account.details['STUN.enable'] === 'true') {
-        iceServers.push({
-          urls: 'stun:' + account.details['STUN.server'],
-        });
-      }
-
-      setWebRtcConnection(new RTCPeerConnection({ iceServers }));
-    }
-  }, [account, webRtcConnection, callData]);
-
   const dependencies = useMemo(
     () => ({
-      webRtcConnection,
       webSocket,
       conversationInfos: callConversationInfos,
       members: callMembers,
-      conversationId: callData?.conversationId,
+      callData: callData,
     }),
-    [webRtcConnection, webSocket, callConversationInfos, callMembers, callData?.conversationId]
+    [webSocket, callConversationInfos, callMembers, callData]
   );
 
   return (
@@ -106,35 +76,30 @@
 
 const useWebRtcContextValue = ({
   members,
-  conversationId,
-  webRtcConnection,
+  callData,
   webSocket,
 }: {
-  webRtcConnection: RTCPeerConnection;
   webSocket: IWebSocketContext;
   members: ConversationMember[];
-  conversationId: string;
+  callData: CallData;
 }) => {
   const [localStream, setLocalStream] = useState<MediaStream>();
   const [screenShareLocalStream, setScreenShareLocalStream] = useState<MediaStream>();
-  const [remoteStreams, setRemoteStreams] = useState<readonly MediaStream[]>();
-  const [iceConnectionState, setIceConnectionState] = useState<RTCIceConnectionState | undefined>();
-
-  const [audioRtcRtpSenders, setAudioRtcRtpSenders] = useState<RTCRtpSender[]>();
-  const [videoRtcRtpSenders, setVideoRtcRtpSenders] = useState<RTCRtpSender[]>();
-
-  // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
-  //       called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
-  //       because messages can be received with a delay or out of order. This queue is a temporary workaround that
-  //       should be replaced if there is a better way to send messages with the daemon.
-  //       Relevant links:
-  //       - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
-  //       - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
-  const [isReadyForIceCandidates, setIsReadyForIceCandidates] = useState(false);
-  const [iceCandidateQueue, setIceCandidateQueue] = useState<RTCIceCandidate[]>([]);
+  const { account } = useAuthContext();
+  const webRtcManager = useWebRtcManager();
 
   // TODO: This logic will have to change to support multiple people in a call
-  const contactUri = useMemo(() => members[0]?.contact.uri, [members]);
+  const contactUri = members[0]?.contact.uri;
+  const connectionInfos = webRtcManager.connectionsInfos[contactUri];
+  const remoteStreams = connectionInfos?.remoteStreams;
+  const iceConnectionState = connectionInfos?.iceConnectionState;
+
+  // 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 getMediaDevices = useCallback(async (): Promise<MediaDevicesInfo> => {
     try {
@@ -222,202 +187,21 @@
     [screenShareLocalStream]
   );
 
+  // TODO: Transform the effect into a callback
+  const updateLocalStreams = webRtcManager.updateLocalStreams;
   useEffect(() => {
-    if ((!localStream && !screenShareLocalStream) || !webRtcConnection) {
+    if ((!localStream && !screenShareLocalStream) || !updateLocalStreams) {
       return;
     }
 
-    const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
-      const senders = kind === 'audio' ? audioRtcRtpSenders : videoRtcRtpSenders;
-      const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
-      if (senders) {
-        const promises: Promise<void>[] = [];
-        for (let i = 0; i < senders.length; i++) {
-          // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
-          //       Calling `addTrack` doesn't trigger the `track` event listener for the other user.
-          //       This workaround makes it possible to replace a track, but it could be improved by figuring out the
-          //       proper way of changing a track.
-          promises.push(
-            senders[i].replaceTrack(tracks[i]).catch((e) => {
-              console.error('Error replacing track:', e);
-            })
-          );
-        }
-        return Promise.all(promises);
-      }
-
-      // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
-      //       the "Perfect negotiation" pattern to renegotiate after `addTrack`.
-      //       https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
-      const newSenders = tracks.map((track) => webRtcConnection.addTrack(track, stream));
-      if (kind === 'audio') {
-        setAudioRtcRtpSenders(newSenders);
-      } else {
-        setVideoRtcRtpSenders(newSenders);
-      }
-    };
-
-    if (localStream) {
-      updateTracks(localStream, 'audio');
-      updateTracks(localStream, 'video');
-    }
-
-    if (screenShareLocalStream) {
-      updateTracks(screenShareLocalStream, 'video');
-    }
-  }, [localStream, screenShareLocalStream, webRtcConnection, audioRtcRtpSenders, videoRtcRtpSenders]);
+    updateLocalStreams(localStream, screenShareLocalStream);
+  }, [localStream, screenShareLocalStream, updateLocalStreams]);
 
   const sendWebRtcOffer = useCallback(async () => {
-    const sdp = await webRtcConnection.createOffer({
-      offerToReceiveAudio: true,
-      offerToReceiveVideo: true,
-    });
-
-    const webRtcOffer: WebRtcSdp = {
-      contactId: contactUri,
-      conversationId: conversationId,
-      sdp,
-    };
-
-    await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
-    console.info('Sending WebRtcOffer', webRtcOffer);
-    webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
-  }, [webRtcConnection, webSocket, conversationId, contactUri]);
-
-  const sendWebRtcAnswer = useCallback(async () => {
-    const sdp = await webRtcConnection.createAnswer({
-      offerToReceiveAudio: true,
-      offerToReceiveVideo: true,
-    });
-
-    const webRtcAnswer: WebRtcSdp = {
-      contactId: contactUri,
-      conversationId: conversationId,
-      sdp,
-    };
-
-    await webRtcConnection.setLocalDescription(new RTCSessionDescription(sdp));
-    console.info('Sending WebRtcAnswer', webRtcAnswer);
-    webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
-  }, [contactUri, conversationId, webRtcConnection, webSocket]);
-
-  /* WebSocket Listeners */
-
-  useEffect(() => {
-    const addQueuedIceCandidates = async () => {
-      console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
-      setIsReadyForIceCandidates(true);
-      if (iceCandidateQueue.length !== 0) {
-        console.warn(
-          'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
-            'Adding queued ICE candidates...',
-          iceCandidateQueue
-        );
-
-        await Promise.all(iceCandidateQueue.map((iceCandidate) => webRtcConnection.addIceCandidate(iceCandidate)));
-      }
-    };
-
-    const webRtcOfferListener = async (data: WebRtcSdp) => {
-      console.info('Received event on WebRtcOffer', data);
-      if (data.conversationId !== conversationId) {
-        console.warn('Wrong incoming conversationId, ignoring action');
-        return;
-      }
-
-      await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
-      await sendWebRtcAnswer();
-      await addQueuedIceCandidates();
-    };
-
-    const webRtcAnswerListener = async (data: WebRtcSdp) => {
-      console.info('Received event on WebRtcAnswer', data);
-      if (data.conversationId !== conversationId) {
-        console.warn('Wrong incoming conversationId, ignoring action');
-        return;
-      }
-
-      await webRtcConnection.setRemoteDescription(new RTCSessionDescription(data.sdp));
-      await addQueuedIceCandidates();
-    };
-
-    webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
-    webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
-      webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
-    };
-  }, [webSocket, webRtcConnection, sendWebRtcAnswer, conversationId, iceCandidateQueue]);
-
-  useEffect(() => {
-    const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
-      if (data.conversationId !== conversationId) {
-        console.warn('Wrong incoming conversationId, ignoring action');
-        return;
-      }
-
-      if (!data.candidate) {
-        return;
-      }
-
-      if (isReadyForIceCandidates) {
-        await webRtcConnection.addIceCandidate(data.candidate);
-      } else {
-        setIceCandidateQueue((v) => {
-          v.push(data.candidate);
-          return v;
-        });
-      }
-    };
-
-    webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
-    };
-  }, [webRtcConnection, webSocket, conversationId, isReadyForIceCandidates]);
-
-  /* WebRTC Listeners */
-
-  useEffect(() => {
-    const iceCandidateEventListener = (event: RTCPeerConnectionIceEvent) => {
-      if (event.candidate) {
-        const webRtcIceCandidate: WebRtcIceCandidate = {
-          contactId: contactUri,
-          conversationId: conversationId,
-          candidate: event.candidate,
-        };
-
-        webSocket.send(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidate);
-      }
-    };
-    webRtcConnection.addEventListener('icecandidate', iceCandidateEventListener);
-
-    return () => {
-      webRtcConnection.removeEventListener('icecandidate', iceCandidateEventListener);
-    };
-  }, [webRtcConnection, webSocket, contactUri, conversationId]);
-
-  useEffect(() => {
-    const trackEventListener = (event: RTCTrackEvent) => {
-      console.info('Received WebRTC event on track', event);
-      setRemoteStreams(event.streams);
-    };
-
-    const iceConnectionStateChangeEventListener = (event: Event) => {
-      console.info(`Received WebRTC event on iceconnectionstatechange: ${webRtcConnection.iceConnectionState}`, event);
-      setIceConnectionState(webRtcConnection.iceConnectionState);
-    };
-
-    webRtcConnection.addEventListener('track', trackEventListener);
-    webRtcConnection.addEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
-
-    return () => {
-      webRtcConnection.removeEventListener('track', trackEventListener);
-      webRtcConnection.removeEventListener('iceconnectionstatechange', iceConnectionStateChangeEventListener);
-    };
-  }, [webRtcConnection]);
+    if (contactUri) {
+      webRtcManager.addConnection(webSocket, account, contactUri, callData, localStream, screenShareLocalStream);
+    }
+  }, [account, callData, contactUri, localStream, screenShareLocalStream, webRtcManager, webSocket]);
 
   const closeConnection = useCallback(() => {
     const stopStream = (stream: MediaStream) => {
@@ -436,8 +220,8 @@
       stopStream(screenShareLocalStream);
     }
 
-    webRtcConnection.close();
-  }, [webRtcConnection, localStream, screenShareLocalStream]);
+    webRtcManager.clean();
+  }, [localStream, screenShareLocalStream, webRtcManager]);
 
   return useMemo(
     () => ({
diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts
index 794fb92..53b8a02 100644
--- a/client/src/utils/utils.ts
+++ b/client/src/utils/utils.ts
@@ -43,3 +43,5 @@
 export const isRequired = <T>(obj: Partial<T>): obj is T => {
   return Object.values(obj).every((v) => v !== undefined);
 };
+
+export type Listener = () => void;
diff --git a/client/src/webrtc/RtcPeerConnectionHandler.ts b/client/src/webrtc/RtcPeerConnectionHandler.ts
new file mode 100644
index 0000000..8d83e5d
--- /dev/null
+++ b/client/src/webrtc/RtcPeerConnectionHandler.ts
@@ -0,0 +1,287 @@
+/*
+ * 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 { WebRtcIceCandidate, WebRtcSdp, WebSocketMessageType } from 'jami-web-common';
+
+import { CallData } from '../contexts/CallManagerProvider';
+import { IWebSocketContext } from '../contexts/WebSocketProvider';
+import { Account } from '../models/account';
+import { Listener } from '../utils/utils';
+
+export type RTCPeerConnectionInfos = {
+  remoteStreams: readonly MediaStream[];
+  iceConnectionState: RTCIceConnectionState;
+};
+
+export class RTCPeerConnectionHandler {
+  private readonly connection: RTCPeerConnection;
+  private remoteStreams: readonly MediaStream[] = [];
+  private iceConnectionState: RTCIceConnectionState = 'new';
+  private isReadyForIceCandidates = false;
+
+  private audioRtcRtpSenders: RTCRtpSender[] | null = null;
+  private videoRtcRtpSenders: RTCRtpSender[] | null = null;
+
+  // TODO: The ICE candidate queue is used to cache candidates that were received before `setRemoteDescription` was
+  //       called. This is currently necessary, because the jami-daemon is unreliable as a WebRTC signaling channel,
+  //       because messages can be received with a delay or out of order. This queue is a temporary workaround that
+  //       should be replaced if there is a better way to send messages with the daemon.
+  //       Relevant links:
+  //       - https://github.com/w3c/webrtc-pc/issues/2519#issuecomment-622055440
+  //       - https://stackoverflow.com/questions/57256828/how-to-fix-invalidstateerror-cannot-add-ice-candidate-when-there-is-no-remote-s
+  private iceCandidateQueue: RTCIceCandidate[] = [];
+
+  private cleaningFunctions: (() => void)[] = [];
+  private listener: Listener;
+
+  getInfos(): RTCPeerConnectionInfos {
+    return {
+      remoteStreams: this.remoteStreams,
+      iceConnectionState: this.iceConnectionState,
+    };
+  }
+
+  constructor(
+    webSocket: IWebSocketContext,
+    account: Account,
+    contactUri: string,
+    callData: CallData,
+    localStream: MediaStream | undefined,
+    screenShareLocalStream: MediaStream | undefined,
+    listener: Listener
+  ) {
+    this.listener = listener;
+    const iceServers = this.getIceServers(account);
+    this.connection = new RTCPeerConnection({ iceServers });
+    this.setConnectionListeners(webSocket, callData.conversationId, contactUri);
+    this.setWebSocketListeners(webSocket, callData.conversationId, contactUri);
+    this.updateLocalStreams(localStream, screenShareLocalStream);
+
+    if (callData.role === 'caller') {
+      this.startNegociation(webSocket, contactUri, callData.conversationId);
+    }
+  }
+
+  updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
+    if (this.connection.iceConnectionState === 'closed') {
+      return;
+    }
+
+    const updateTracks = async (stream: MediaStream, kind: 'audio' | 'video') => {
+      const senders = kind === 'audio' ? this.audioRtcRtpSenders : this.videoRtcRtpSenders;
+      const tracks = kind === 'audio' ? stream.getAudioTracks() : stream.getVideoTracks();
+      if (senders) {
+        const promises: Promise<void>[] = [];
+        for (let i = 0; i < senders.length; i++) {
+          // TODO: There is a bug where calling multiple times `addTrack` when changing an input device doesn't work.
+          //       Calling `addTrack` doesn't trigger the `track` event listener for the other user.
+          //       This workaround makes it possible to replace a track, but it could be improved by figuring out the
+          //       proper way of changing a track.
+          promises.push(
+            senders[i].replaceTrack(tracks[i]).catch((e) => {
+              console.error('Error replacing track:', e);
+            })
+          );
+        }
+        return Promise.all(promises);
+      }
+
+      // TODO: Currently, we do not support adding new devices. To enable this feature, we would need to implement
+      //       the "Perfect negotiation" pattern to renegotiate after `addTrack`.
+      //       https://blog.mozilla.org/webrtc/perfect-negotiation-in-webrtc/
+      const newSenders = tracks.map((track) => this.connection.addTrack(track, stream));
+      if (kind === 'audio') {
+        this.audioRtcRtpSenders = newSenders;
+      } else {
+        this.videoRtcRtpSenders = newSenders;
+      }
+    };
+
+    if (localStream) {
+      updateTracks(localStream, 'audio');
+      updateTracks(localStream, 'video');
+    }
+
+    if (screenShareLocalStream) {
+      updateTracks(screenShareLocalStream, 'video');
+    }
+  }
+
+  disconnect() {
+    this.connection?.close();
+    this.cleaningFunctions.forEach((func) => func());
+    this.cleaningFunctions = [];
+    this.emitChange();
+  }
+
+  private getIceServers(account: Account) {
+    const iceServers: RTCIceServer[] = [];
+
+    if (account.details['TURN.enable'] === 'true') {
+      iceServers.push({
+        urls: 'turn:' + account.details['TURN.server'],
+        username: account.details['TURN.username'],
+        credential: account.details['TURN.password'],
+      });
+    }
+
+    if (account.details['STUN.enable'] === 'true') {
+      iceServers.push({
+        urls: 'stun:' + account.details['STUN.server'],
+      });
+    }
+
+    return iceServers;
+  }
+
+  private startNegociation(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+    this.sendWebRtcOffer(webSocket, contactUri, conversationId);
+  }
+
+  private async sendWebRtcOffer(webSocket: IWebSocketContext, contactUri: string, conversationId: string) {
+    const sdp = await this.connection.createOffer({
+      offerToReceiveAudio: true,
+      offerToReceiveVideo: true,
+    });
+
+    const webRtcOffer: WebRtcSdp = {
+      contactId: contactUri,
+      conversationId: conversationId,
+      sdp,
+    };
+
+    await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+    console.info('Sending WebRtcOffer', webRtcOffer);
+    webSocket.send(WebSocketMessageType.WebRtcOffer, webRtcOffer);
+  }
+
+  private setWebSocketListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    const sendWebRtcAnswer = async () => {
+      const sdp = await this.connection.createAnswer({
+        offerToReceiveAudio: true,
+        offerToReceiveVideo: true,
+      });
+
+      const webRtcAnswer: WebRtcSdp = {
+        contactId: contactUri,
+        conversationId: conversationId,
+        sdp,
+      };
+
+      await this.connection.setLocalDescription(new RTCSessionDescription(sdp));
+      console.info('Sending WebRtcAnswer', webRtcAnswer);
+      webSocket.send(WebSocketMessageType.WebRtcAnswer, webRtcAnswer);
+    };
+
+    const addQueuedIceCandidates = async () => {
+      console.info('WebRTC remote description has been set. Ready to receive ICE candidates');
+      this.isReadyForIceCandidates = true;
+      if (this.iceCandidateQueue.length !== 0) {
+        console.warn(
+          'Found queued ICE candidates that were added before `setRemoteDescription` was called. ' +
+            'Adding queued ICE candidates...',
+          this.iceCandidateQueue
+        );
+
+        await Promise.all(this.iceCandidateQueue.map((iceCandidate) => this.connection.addIceCandidate(iceCandidate)));
+      }
+    };
+
+    const webRtcOfferListener = async (data: WebRtcSdp) => {
+      console.debug('receive webrtcoffer');
+      console.info('Received event on WebRtcOffer', data);
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+      await sendWebRtcAnswer();
+      await addQueuedIceCandidates();
+    };
+
+    const webRtcAnswerListener = async (data: WebRtcSdp) => {
+      console.info('Received event on WebRtcAnswer', data);
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      await this.connection.setRemoteDescription(new RTCSessionDescription(data.sdp));
+      await addQueuedIceCandidates();
+    };
+
+    const webRtcIceCandidateListener = async (data: WebRtcIceCandidate) => {
+      if (data.conversationId !== conversationId) {
+        console.warn('Wrong incoming conversationId, ignoring action');
+        return;
+      }
+
+      if (!data.candidate) {
+        return;
+      }
+
+      if (this.isReadyForIceCandidates) {
+        await this.connection.addIceCandidate(data.candidate);
+      } else {
+        this.iceCandidateQueue.push(data.candidate);
+      }
+    };
+
+    webSocket.bind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+    webSocket.bind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+    webSocket.bind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+
+    this.cleaningFunctions.push(() => {
+      webSocket.unbind(WebSocketMessageType.WebRtcOffer, webRtcOfferListener);
+      webSocket.unbind(WebSocketMessageType.WebRtcAnswer, webRtcAnswerListener);
+      webSocket.unbind(WebSocketMessageType.WebRtcIceCandidate, webRtcIceCandidateListener);
+    });
+  }
+
+  private setConnectionListeners(webSocket: IWebSocketContext, conversationId: string, contactUri: string) {
+    this.connection.onicecandidate = (event: RTCPeerConnectionIceEvent) => {
+      if (event.candidate) {
+        const webRtcIceCandidate: WebRtcIceCandidate = {
+          contactId: 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);
+      }
+    };
+
+    this.connection.ontrack = (event: RTCTrackEvent) => {
+      console.info('Received WebRTC event on track', event);
+      this.remoteStreams = event.streams;
+      this.emitChange();
+    };
+
+    this.connection.oniceconnectionstatechange = (event: Event) => {
+      console.info(`Received WebRTC event on iceconnectionstatechange: ${this.connection.iceConnectionState}`, event);
+      this.iceConnectionState = this.connection.iceConnectionState;
+      this.emitChange();
+    };
+  }
+
+  private emitChange() {
+    this.listener();
+  }
+}
diff --git a/client/src/webrtc/WebRtcManager.ts b/client/src/webrtc/WebRtcManager.ts
new file mode 100644
index 0000000..afae8a0
--- /dev/null
+++ b/client/src/webrtc/WebRtcManager.ts
@@ -0,0 +1,111 @@
+/*
+ * 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 { useMemo, useRef, useSyncExternalStore } from 'react';
+
+import { CallData } from '../contexts/CallManagerProvider';
+import { IWebSocketContext } from '../contexts/WebSocketProvider';
+import { Account } from '../models/account';
+import { Listener } from '../utils/utils';
+import { RTCPeerConnectionHandler, RTCPeerConnectionInfos } from './RtcPeerConnectionHandler';
+
+export const useWebRtcManager = () => {
+  const webRtcManagerRef = useRef(new WebRtcManager());
+  const connectionsInfos = useSyncExternalStore(
+    webRtcManagerRef.current.subscribe.bind(webRtcManagerRef.current),
+    webRtcManagerRef.current.getSnapshot.bind(webRtcManagerRef.current)
+  );
+
+  return useMemo(
+    () => ({
+      addConnection: webRtcManagerRef.current.addConnection.bind(webRtcManagerRef.current),
+      removeConnection: webRtcManagerRef.current.removeConnection.bind(webRtcManagerRef.current),
+      updateLocalStreams: webRtcManagerRef.current.updateLocalStreams.bind(webRtcManagerRef.current),
+      clean: webRtcManagerRef.current.clean.bind(webRtcManagerRef.current),
+      connectionsInfos: connectionsInfos,
+    }),
+    [connectionsInfos]
+  );
+};
+
+class WebRtcManager {
+  private connections: Record<string, RTCPeerConnectionHandler> = {}; // key is contactUri
+
+  private listeners: Listener[] = [];
+  private snapshot: Record<string, RTCPeerConnectionInfos> = {}; // key is contactUri
+
+  addConnection(
+    webSocket: IWebSocketContext,
+    account: Account,
+    contactUri: string,
+    callData: CallData,
+    localStream: MediaStream | undefined,
+    screenShareLocalStream: MediaStream | undefined
+  ) {
+    if (this.connections[contactUri]) {
+      console.debug('Attempted to establish an WebRTC connection with the same peer more than once');
+      return;
+    }
+
+    const connection = new RTCPeerConnectionHandler(
+      webSocket,
+      account,
+      contactUri,
+      callData,
+      localStream,
+      screenShareLocalStream,
+      this.emitChange.bind(this)
+    );
+    this.connections[contactUri] = connection;
+  }
+
+  removeConnection(contactUri: string) {
+    const connection = this.connections[contactUri];
+    connection.disconnect();
+    delete this.connections[contactUri];
+  }
+
+  updateLocalStreams(localStream: MediaStream | undefined, screenShareLocalStream: MediaStream | undefined) {
+    Object.values(this.connections).forEach((connection) =>
+      connection.updateLocalStreams(localStream, screenShareLocalStream)
+    );
+  }
+
+  subscribe(listener: Listener) {
+    this.listeners.push(listener);
+    return () => {
+      this.listeners.filter((otherListener) => otherListener !== listener);
+    };
+  }
+
+  getSnapshot(): Record<string, RTCPeerConnectionInfos> {
+    return this.snapshot;
+  }
+
+  emitChange() {
+    this.snapshot = Object.entries(this.connections).reduce((acc, [contactUri, connection]) => {
+      acc[contactUri] = connection.getInfos();
+      return acc;
+    }, {} as Record<string, RTCPeerConnectionInfos>);
+
+    this.listeners.forEach((listener) => listener());
+  }
+
+  clean() {
+    Object.values(this.connections).forEach((connection) => connection.disconnect());
+  }
+}