Move WebSocket client to its own class

- Make the WebSocket client independent from the React's hooks and re-renders. It is intended to make the WebSocket connection more stable and easier to manage.
- Add 'sendQueue' to not lose messages while WebSocket is disconnected. Will need more work: not all messages should be sent after delay.
- Make WebSocketProvider always return a WebSocketClient

Change-Id: If6c967f7f558d90c82f8fd023058492dfc5b8735
diff --git a/client/src/contexts/WebSocketProvider.tsx b/client/src/contexts/WebSocketProvider.tsx
index e8ef017..44d54c0 100644
--- a/client/src/contexts/WebSocketProvider.tsx
+++ b/client/src/contexts/WebSocketProvider.tsx
@@ -15,153 +15,38 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { WebSocketMessage, WebSocketMessageTable, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { createContext, useEffect, useMemo, useRef } from 'react';
 
+import { WebSocketClient } from '../services/WebSocketClient';
 import { apiUrl } from '../utils/constants';
 import { WithChildren } from '../utils/utils';
 import { useAuthContext } from './AuthProvider';
 
-type WebSocketCallback<T extends WebSocketMessageType> = (data: WebSocketMessageTable[T]) => void;
-
-type WebSocketCallbacks = {
-  [key in WebSocketMessageType]: Set<WebSocketCallback<key>>;
-};
-
-const buildWebSocketCallbacks = (): WebSocketCallbacks => {
-  const webSocketCallback = {} as WebSocketCallbacks;
-  for (const messageType of Object.values(WebSocketMessageType)) {
-    // TODO: type this properly to prevent mistakes
-    // The end result of the function is still typed properly
-    webSocketCallback[messageType] = new Set() as any;
-  }
-  return webSocketCallback;
-};
-
-type BindFunction = <T extends WebSocketMessageType>(
-  type: T,
-  callback: (data: WebSocketMessageTable[T]) => void
-) => void;
-type SendFunction = <T extends WebSocketMessageType>(type: T, data: WebSocketMessageTable[T]) => void;
-
 export interface IWebSocketContext {
-  bind: BindFunction;
-  unbind: BindFunction;
-  send: SendFunction;
+  bind: WebSocketClient['bind'];
+  unbind: WebSocketClient['unbind'];
+  send: WebSocketClient['send'];
 }
 
 export const WebSocketContext = createContext<IWebSocketContext | undefined>(undefined);
 
 export default ({ children }: WithChildren) => {
-  const [isConnected, setIsConnected] = useState(false);
-  const webSocketRef = useRef<WebSocket>();
-  const callbacksRef = useRef<WebSocketCallbacks>(buildWebSocketCallbacks());
-  const reconnectionTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
+  const webSocketClientRef = useRef<WebSocketClient>(new WebSocketClient());
 
   const { token: accessToken } = useAuthContext();
 
-  const bind: BindFunction = useCallback((type, callback) => {
-    const callbacks = callbacksRef.current[type];
-    callbacks.add(callback);
-  }, []);
-
-  const unbind: BindFunction = useCallback((type, callback) => {
-    const callbacks = callbacksRef.current[type];
-    callbacks.delete(callback);
-  }, []);
-
-  const send: SendFunction = useCallback(
-    (type, data) => {
-      if (isConnected) {
-        webSocketRef.current?.send(JSON.stringify({ type, data }));
-      }
-    },
-    [isConnected]
-  );
-
-  const connect = useCallback(() => {
-    const url = new URL(apiUrl);
-    url.protocol = 'ws:';
-    url.searchParams.set('accessToken', accessToken);
-
-    const webSocket = new WebSocket(url);
-
-    const close = (reconnect = false) => {
-      console.debug('WebSocket disconnected');
-      setIsConnected(false);
-      for (const callbacks of Object.values(callbacksRef.current)) {
-        callbacks.clear();
-      }
-      if (reconnect) {
-        reconnectionTimeoutRef.current = setTimeout(connect, 2000);
-      }
-    };
-
-    webSocket.onopen = () => {
-      console.debug('WebSocket connected');
-      setIsConnected(true);
-    };
-
-    webSocket.onclose = () => close(true);
-
-    webSocket.onmessage = <T extends WebSocketMessageType>(event: MessageEvent<string>) => {
-      const messageString = event.data;
-      console.debug('WebSocket received message', messageString);
-
-      const message: WebSocketMessage<T> = JSON.parse(messageString);
-      if (!message.type || !message.data) {
-        console.warn('WebSocket message is not a valid WebSocketMessage (missing type or data fields)');
-        return;
-      }
-
-      if (!Object.values(WebSocketMessageType).includes(message.type)) {
-        console.warn(`Invalid WebSocket message type: ${message.type}`);
-        return;
-      }
-
-      const callbacks = callbacksRef.current[message.type];
-      for (const callback of callbacks) {
-        callback(message.data);
-      }
-    };
-
-    webSocket.onerror = (event: Event) => {
-      console.error('WebSocket errored', event);
-    };
-
-    webSocketRef.current = webSocket;
-
-    return () => {
-      // Cancel any previous reconnection attempt
-      if (reconnectionTimeoutRef.current !== undefined) {
-        clearTimeout(reconnectionTimeoutRef.current);
-        reconnectionTimeoutRef.current = undefined;
-      }
-
-      // Setup a closure without reconnection
-      webSocket.onclose = () => close();
-
-      switch (webSocket.readyState) {
-        case webSocket.CONNECTING:
-          webSocket.onopen = () => webSocket.close();
-          break;
-        case webSocket.OPEN:
-          webSocket.close();
-          break;
-      }
-    };
+  useEffect(() => {
+    webSocketClientRef.current.connect(apiUrl, accessToken);
   }, [accessToken]);
 
-  useEffect(connect, [connect]);
-
   const value: IWebSocketContext = useMemo(
     () => ({
-      bind,
-      unbind,
-      send,
+      bind: webSocketClientRef.current.bind.bind(webSocketClientRef.current),
+      unbind: webSocketClientRef.current.unbind.bind(webSocketClientRef.current),
+      send: webSocketClientRef.current.send.bind(webSocketClientRef.current),
     }),
-    [bind, unbind, send]
+    []
   );
 
-  return <WebSocketContext.Provider value={isConnected ? value : undefined}>{children}</WebSocketContext.Provider>;
+  return <WebSocketContext.Provider value={value}>{children}</WebSocketContext.Provider>;
 };