Add WebSocket connection to client

Changes:
- On successful login, create a barebone WebSocket
- The access token is used for the authentification

GitLab: #49
Change-Id: I9aee9125fb8eb25273b198054909927350177b72
diff --git a/client/src/contexts/AuthProvider.tsx b/client/src/contexts/AuthProvider.tsx
index 50b1723..3bac10d 100644
--- a/client/src/contexts/AuthProvider.tsx
+++ b/client/src/contexts/AuthProvider.tsx
@@ -18,10 +18,11 @@
 import { Account } from 'jami-web-common/dist/Account';
 import { HttpStatusCode } from 'jami-web-common/dist/enums/http-status-code';
 import { createContext, useCallback, useContext, useEffect, useState } from 'react';
-import { Outlet, useNavigate } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import ProcessingRequest from '../components/ProcessingRequest';
 import { apiUrl } from '../utils/constants';
+import { WithChildren } from '../utils/utils';
 
 interface IAuthContext {
   token: string;
@@ -31,7 +32,7 @@
 
 const AuthContext = createContext<IAuthContext | undefined>(undefined);
 
-export default () => {
+export default ({ children }: WithChildren) => {
   const [token, setToken] = useState<string | undefined>();
   const [account, setAccount] = useState<Account | undefined>();
   const navigate = useNavigate();
@@ -91,7 +92,7 @@
         account,
       }}
     >
-      <Outlet />
+      {children}
     </AuthContext.Provider>
   );
 };
diff --git a/client/src/contexts/WebSocketProvider.tsx b/client/src/contexts/WebSocketProvider.tsx
new file mode 100644
index 0000000..86b794f
--- /dev/null
+++ b/client/src/contexts/WebSocketProvider.tsx
@@ -0,0 +1,104 @@
+/*
+ * 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 { WebSocketMessage, WebSocketMessageType } from 'jami-web-common';
+import { createContext, useCallback, useEffect, useRef, useState } from 'react';
+
+import { apiUrl } from '../utils/constants';
+import { WithChildren } from '../utils/utils';
+import { useAuthContext } from './AuthProvider';
+
+export type WebSocketMessageFn = (message: WebSocketMessage) => void;
+
+interface IWebSocketContext {
+  bind: (type: WebSocketMessageType, callback: WebSocketMessageFn) => void;
+  send: WebSocketMessageFn;
+}
+
+export const WebSocketContext = createContext<IWebSocketContext | undefined>(undefined);
+
+export default ({ children }: WithChildren) => {
+  const [isConnected, setIsConnected] = useState(false);
+  const webSocketRef = useRef<WebSocket>();
+  const callbacksRef = useRef(new Map<WebSocketMessageType, WebSocketMessageFn[]>());
+
+  const { token: accessToken } = useAuthContext();
+
+  const bind = useCallback((type: WebSocketMessageType, messageCallback: WebSocketMessageFn) => {
+    const messageCallbacks = callbacksRef.current.get(type);
+    if (messageCallbacks) {
+      messageCallbacks.push(messageCallback);
+    } else {
+      callbacksRef.current.set(type, [messageCallback]);
+    }
+  }, []);
+
+  const send = useCallback(
+    (message: WebSocketMessage) => {
+      if (isConnected) {
+        webSocketRef.current?.send(JSON.stringify(message));
+      }
+    },
+    [isConnected]
+  );
+
+  const handleOnOpen = useCallback(() => setIsConnected(true), []);
+
+  const handleOnClose = useCallback(() => {
+    setIsConnected(false);
+    callbacksRef.current.clear();
+  }, []);
+
+  const handleOnMessage = useCallback(({ data }: MessageEvent<string>) => {
+    const message: WebSocketMessage = JSON.parse(data);
+    const messageCallbacks = callbacksRef.current.get(message.type);
+    if (messageCallbacks) {
+      for (const messageCallback of messageCallbacks) {
+        messageCallback(message);
+      }
+    } else {
+      console.warn(`Unhandled message of type ${message.type}`);
+    }
+  }, []);
+
+  const handleOnError = useCallback((event: Event) => {
+    console.error('Closing WebSocket due to an error:', event);
+    webSocketRef.current?.close();
+  }, []);
+
+  useEffect(() => {
+    const url = new URL(apiUrl);
+    url.protocol = 'ws:';
+    url.searchParams.set('accessToken', accessToken);
+
+    const webSocket = new WebSocket(url);
+    webSocket.onopen = handleOnOpen;
+    webSocket.onclose = handleOnClose;
+    webSocket.onmessage = handleOnMessage;
+    webSocket.onerror = handleOnError;
+
+    webSocketRef.current = webSocket;
+
+    return () => webSocket.close();
+  }, [accessToken, handleOnOpen, handleOnClose, handleOnMessage, handleOnError]);
+
+  return isConnected ? (
+    <WebSocketContext.Provider value={{ bind, send }}>{children}</WebSocketContext.Provider>
+  ) : (
+    <>{children}</>
+  );
+};
diff --git a/client/src/index.tsx b/client/src/index.tsx
index 968e62b..49ed2ef 100644
--- a/client/src/index.tsx
+++ b/client/src/index.tsx
@@ -24,13 +24,14 @@
 import { StrictMode } from 'react';
 import { createRoot } from 'react-dom/client';
 import { Provider } from 'react-redux';
-import { createBrowserRouter, createRoutesFromElements, Route, RouterProvider } from 'react-router-dom';
+import { createBrowserRouter, createRoutesFromElements, Outlet, Route, RouterProvider } from 'react-router-dom';
 import socketio from 'socket.io-client';
 
 import App from './App';
 import ContactList from './components/ContactList';
 import AuthProvider from './contexts/AuthProvider';
 import { SocketProvider } from './contexts/Socket';
+import WebSocketProvider from './contexts/WebSocketProvider';
 import AccountSelection from './pages/AccountSelection';
 import AccountSettings from './pages/AccountSettings';
 import CallInterface from './pages/CallInterface';
@@ -58,7 +59,15 @@
     <Route path="/" element={<App />}>
       <Route index element={<Welcome />} />
       <Route path="theme" element={<ThemeDemonstrator />} />
-      <Route element={<AuthProvider />}>
+      <Route
+        element={
+          <AuthProvider>
+            <WebSocketProvider>
+              <Outlet />
+            </WebSocketProvider>
+          </AuthProvider>
+        }
+      >
         <Route path="account" element={<JamiMessenger />} />
         <Route path="settings" element={<AccountSettings />} />
         <Route path="contacts" element={<ContactList />} />