Redirect user from call page when not in call

When a user tries to access a call page while not in a call, redirect the user to the home page.

Misc changes:

- Add route state to the call route that includes the CallStatus.
- CallProvider redirects to home if the callStatus isn't set (meaning
  the user isn't in a call).
- Remove `beginCall` function in `ConversationProvider`. Added `useStartCall` hook that redirects the user to the call page. The `CallProvider` automatically sends the `BeginCall` message when the user reaches the page for the first time.
- Reorder functions in CallProvider to have `useEffect` functions at the top

GitLab: #164
Change-Id: I6cec1b9f31cb308d92a69112f5b38d1bdf79e05f
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index a1e6693..48e9004 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -23,6 +23,7 @@
 import { useNavigate, useParams } from 'react-router-dom';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { useStartCall } from '../hooks/useStartCall';
 import { setRefreshFromSlice } from '../redux/appSlice';
 import { useAppDispatch } from '../redux/hooks';
 import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu';
@@ -51,7 +52,12 @@
   const isSelected = conversation.getDisplayUri() === pathId;
   const navigate = useNavigate();
   const userId = conversation?.getFirstMember()?.contact.getUri();
-  const uri = conversation.getId() ? `/conversation/${conversation.getId()}` : `/conversation/add-contact/${userId}`;
+
+  // TODO: Improve this component. conversationId should never be undefined.
+  //       (https://git.jami.net/savoirfairelinux/jami-web/-/issues/171)
+  const uri = conversation.getId()
+    ? `/conversation/${conversation.getId()}`
+    : `/conversation/add-contact?newContactId=${userId}`;
   return (
     <Box onContextMenu={contextMenuHandler.handleAnchorPosition}>
       <ConversationMenu
@@ -90,6 +96,8 @@
 
   const navigate = useNavigate();
 
+  const startCall = useStartCall();
+
   const getContactDetails = useCallback(async () => {
     const controller = new AbortController();
     try {
@@ -102,6 +110,8 @@
     }
   }, [axiosInstance, userId]);
 
+  const conversationId = conversation.getId();
+
   const menuOptions: PopoverListItemData[] = useMemo(
     () => [
       {
@@ -115,14 +125,20 @@
         label: t('conversation_start_audiocall'),
         Icon: AudioCallIcon,
         onClick: () => {
-          navigate(`/account/call/${conversation.getId()}`);
+          if (conversationId) {
+            startCall(conversationId);
+          }
         },
       },
       {
         label: t('conversation_start_videocall'),
         Icon: VideoCallIcon,
         onClick: () => {
-          navigate(`call/${conversation.getId()}?video=true`);
+          if (conversationId) {
+            startCall(conversationId, {
+              isVideoOn: true,
+            });
+          }
         },
       },
       ...(isSelected
@@ -160,7 +176,6 @@
       },
     ],
     [
-      conversation,
       navigate,
       uri,
       isSelected,
@@ -169,6 +184,8 @@
       blockContactDialogHandler,
       removeContactDialogHandler,
       t,
+      startCall,
+      conversationId,
     ]
   );
 
diff --git a/client/src/components/ConversationView.tsx b/client/src/components/ConversationView.tsx
index c638295..52215e8 100644
--- a/client/src/components/ConversationView.tsx
+++ b/client/src/components/ConversationView.tsx
@@ -22,6 +22,7 @@
 
 import { useAuthContext } from '../contexts/AuthProvider';
 import { ConversationContext } from '../contexts/ConversationProvider';
+import { useStartCall } from '../hooks/useStartCall';
 import ChatInterface from '../pages/ChatInterface';
 import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
 import { AddParticipantButton, ShowOptionsMenuButton, StartAudioCallButton, StartVideoCallButton } from './Button';
@@ -57,7 +58,7 @@
 
 const ConversationHeader = ({ account, members, adminTitle }: ConversationHeaderProps) => {
   const { t } = useTranslation();
-  const { beginCall } = useContext(ConversationContext);
+  const { conversationId } = useContext(ConversationContext);
 
   const title = useMemo(() => {
     if (adminTitle) {
@@ -82,6 +83,8 @@
     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">
@@ -90,8 +93,14 @@
         </Typography>
       </Stack>
       <Stack direction="row" spacing="20px">
-        <StartAudioCallButton onClick={() => beginCall()} />
-        <StartVideoCallButton onClick={() => beginCall()} />
+        <StartAudioCallButton onClick={() => startCall(conversationId)} />
+        <StartVideoCallButton
+          onClick={() =>
+            startCall(conversationId, {
+              isVideoOn: true,
+            })
+          }
+        />
         <AddParticipantButton />
         <ShowOptionsMenuButton />
       </Stack>
diff --git a/client/src/contexts/CallProvider.tsx b/client/src/contexts/CallProvider.tsx
index a17e738..de2a6d7 100644
--- a/client/src/contexts/CallProvider.tsx
+++ b/client/src/contexts/CallProvider.tsx
@@ -29,6 +29,7 @@
 export type CallRole = 'caller' | 'receiver';
 
 export enum CallStatus {
+  Default,
   Ringing,
   Connecting,
   InCall,
@@ -65,7 +66,7 @@
   isVideoOn: false,
   setVideoStatus: () => {},
   callRole: 'caller',
-  callStatus: CallStatus.Ringing,
+  callStatus: CallStatus.Default,
 
   acceptCall: () => {},
 };
@@ -75,6 +76,7 @@
 export default ({ children }: WithChildren) => {
   const {
     queryParams: { role: callRole },
+    state: routeState,
   } = useUrlParams<CallRouteParams>();
   const webSocket = useContext(WebSocketContext);
   const { webRtcConnection, remoteStreams, sendWebRtcOffer, isConnected } = useContext(WebRtcContext);
@@ -87,9 +89,11 @@
 
   const [isAudioOn, setIsAudioOn] = useState(false);
   const [isVideoOn, setIsVideoOn] = useState(false);
-  const [callStatus, setCallStatus] = useState(CallStatus.Ringing);
+  const [callStatus, setCallStatus] = useState(routeState?.callStatus);
 
-  // TODO: This logic will have to change to support multiple people in a call
+  // TODO: This logic will have to change to support multiple people in a call. Could we move this logic to the server?
+  //       The client could make a single request with the conversationId, and the server would be tasked with sending
+  //       all the individual requests to the members of the conversation.
   const contactUri = useMemo(() => conversation.getFirstMember().contact.getUri(), [conversation]);
 
   useEffect(() => {
@@ -140,35 +144,22 @@
     }
   }, [localStream, webRtcConnection]);
 
-  const setAudioStatus = useCallback(
-    (isOn: boolean) => {
-      if (!localStream) {
-        return;
-      }
+  useEffect(() => {
+    if (!webSocket) {
+      return;
+    }
 
-      for (const track of localStream.getAudioTracks()) {
-        track.enabled = isOn;
-      }
+    if (callRole === 'caller' && callStatus === CallStatus.Default) {
+      const callBegin: CallAction = {
+        contactId: contactUri,
+        conversationId,
+      };
 
-      setIsAudioOn(isOn);
-    },
-    [localStream]
-  );
-
-  const setVideoStatus = useCallback(
-    (isOn: boolean) => {
-      if (!localStream) {
-        return;
-      }
-
-      for (const track of localStream.getVideoTracks()) {
-        track.enabled = isOn;
-      }
-
-      setIsVideoOn(isOn);
-    },
-    [localStream]
-  );
+      console.info('Sending CallBegin', callBegin);
+      webSocket.send(WebSocketMessageType.CallBegin, callBegin);
+      setCallStatus(CallStatus.Ringing);
+    }
+  }, [webSocket, callRole, callStatus, contactUri, conversationId]);
 
   useEffect(() => {
     if (!webSocket || !webRtcConnection) {
@@ -220,8 +211,38 @@
     setCallStatus(CallStatus.Connecting);
   }, [webSocket, contactUri, conversationId]);
 
-  if (!callRole) {
-    console.error('Call role not defined. Redirecting...');
+  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]
+  );
+
+  if (!callRole || callStatus === undefined) {
+    console.error('Invalid route. Redirecting...');
     return <Navigate to={'/'} />;
   }
 
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
index a282664..7d09881 100644
--- a/client/src/contexts/ConversationProvider.tsx
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -15,9 +15,8 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { CallAction, Conversation, ConversationView, WebSocketMessageType } from 'jami-web-common';
-import { createContext, useCallback, useContext, useEffect, useState } from 'react';
-import { useNavigate } from 'react-router-dom';
+import { Conversation, ConversationView, WebSocketMessageType } from 'jami-web-common';
+import { createContext, useContext, useEffect, useState } from 'react';
 
 import LoadingPage from '../components/Loading';
 import { useUrlParams } from '../hooks/useUrlParams';
@@ -30,8 +29,6 @@
 interface IConversationProvider {
   conversationId: string;
   conversation: Conversation;
-
-  beginCall: () => void;
 }
 
 export const ConversationContext = createContext<IConversationProvider>(undefined!);
@@ -45,9 +42,8 @@
   const [isLoading, setIsLoading] = useState(false);
   const [isError, setIsError] = useState(false);
   const [conversation, setConversation] = useState<Conversation | undefined>();
-  const navigate = useNavigate();
 
-  const conversationQuery = useConversationQuery(conversationId);
+  const conversationQuery = useConversationQuery(conversationId!);
 
   useEffect(() => {
     if (conversationQuery.isSuccess) {
@@ -64,28 +60,8 @@
     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: CallAction = {
-        contactId: member.contact.getUri(),
-        conversationId,
-      };
-
-      console.info('Sending CallBegin', callBegin);
-      webSocket.send(WebSocketMessageType.CallBegin, callBegin);
-    }
-
-    navigate(`/conversation/${conversationId}/call?role=caller`);
-  }, [conversationId, webSocket, conversation, navigate]);
-
   useEffect(() => {
-    if (!conversation || !webSocket) {
+    if (!conversation || !conversationId || !webSocket) {
       return;
     }
 
@@ -108,7 +84,6 @@
       value={{
         conversationId,
         conversation,
-        beginCall,
       }}
     >
       {children}
diff --git a/client/src/hooks/useStartCall.ts b/client/src/hooks/useStartCall.ts
new file mode 100644
index 0000000..2df6a01
--- /dev/null
+++ b/client/src/hooks/useStartCall.ts
@@ -0,0 +1,38 @@
+/*
+ * 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?role=caller`, {
+        state: {
+          callStatus: CallStatus.Default,
+          ...state,
+        },
+      });
+    },
+    [navigate]
+  );
+};
diff --git a/client/src/hooks/useUrlParams.ts b/client/src/hooks/useUrlParams.ts
index f21b60b..243a8c5 100644
--- a/client/src/hooks/useUrlParams.ts
+++ b/client/src/hooks/useUrlParams.ts
@@ -18,13 +18,14 @@
 import { useMemo } from 'react';
 import { useLocation, useParams } from 'react-router-dom';
 
-export type RouteParams<U = Record<string, string>, Q = Record<string, string>> = {
-  urlParams: U;
-  queryParams: Q;
+export type RouteParams<UrlParams = Record<string, string>, QueryParams = Record<string, string>, State = any> = {
+  urlParams: UrlParams;
+  queryParams: QueryParams;
+  state?: State;
 };
 
 export const useUrlParams = <T extends RouteParams>() => {
-  const { search } = useLocation();
+  const { search, state } = useLocation();
   const urlParams = useParams() as T['urlParams'];
 
   return useMemo(() => {
@@ -32,6 +33,7 @@
     return {
       queryParams,
       urlParams,
+      state: state as T['state'],
     };
-  }, [search, urlParams]);
+  }, [search, urlParams, state]);
 };
diff --git a/client/src/managers/NotificationManager.tsx b/client/src/managers/NotificationManager.tsx
index 4154c33..dacc3c1 100644
--- a/client/src/managers/NotificationManager.tsx
+++ b/client/src/managers/NotificationManager.tsx
@@ -20,6 +20,7 @@
 import { useNavigate } from 'react-router-dom';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { CallStatus } from '../contexts/CallProvider';
 import { WebSocketContext } from '../contexts/WebSocketProvider';
 import { WithChildren } from '../utils/utils';
 
@@ -38,7 +39,11 @@
 
     const callBeginListener = (data: CallAction) => {
       console.info('Received event on CallBegin', data);
-      navigate(`/conversation/${data.conversationId}/call?role=receiver`);
+      navigate(`/conversation/${data.conversationId}/call?role=receiver`, {
+        state: {
+          callStatus: CallStatus.Ringing,
+        },
+      });
     };
 
     webSocket.bind(WebSocketMessageType.CallBegin, callBeginListener);
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index c5c0922..b6266ac 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -54,7 +54,7 @@
     return (
       <CallPending
         pending={callRole}
-        caller={callStatus === CallStatus.Ringing ? 'calling' : 'connecting'}
+        caller={callStatus === CallStatus.Connecting ? 'connecting' : 'calling'}
         medium="audio"
       />
     );
diff --git a/client/src/pages/JamiRegistration.tsx b/client/src/pages/JamiRegistration.tsx
index 089c5d8..619b230 100644
--- a/client/src/pages/JamiRegistration.tsx
+++ b/client/src/pages/JamiRegistration.tsx
@@ -126,7 +126,7 @@
 
     if (canCreate) {
       setIsCreatingUser(true);
-      createAccount();
+      await createAccount();
     } else {
       if (usernameError || username.length === 0) {
         setUsernameStatus('registration_failed');
diff --git a/client/src/pages/Messenger.tsx b/client/src/pages/Messenger.tsx
index c2bc1e7..7f10e41 100644
--- a/client/src/pages/Messenger.tsx
+++ b/client/src/pages/Messenger.tsx
@@ -43,9 +43,11 @@
   const [searchQuery, setSearchQuery] = useState('');
   const [searchResult, setSearchResults] = useState<Conversation | undefined>(undefined);
 
-  const {
-    urlParams: { contactId },
-  } = useUrlParams<AddContactRouteParams>();
+  const { urlParams } = useUrlParams<AddContactRouteParams>();
+
+  // TODO: Rework the contact adding logic so that adding a contact does not make the current conversationId undefined.
+  //       The newContactId should not come from the route, but from a state.
+  const newContactId = urlParams?.contactId;
 
   const accountId = account.getId();
 
@@ -101,7 +103,7 @@
       <Stack flexGrow={0} flexShrink={0} overflow="auto">
         <Header />
         <NewContactForm onChange={setSearchQuery} />
-        {contactId && <AddContactPage contactId={contactId} />}
+        {newContactId && <AddContactPage contactId={newContactId} />}
         {conversations ? (
           <ConversationList search={searchResult} conversations={conversations} accountId={accountId} />
         ) : (
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 8e53188..5b6b71d 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -21,7 +21,7 @@
 import ContactList from './components/ContactList';
 import ConversationView from './components/ConversationView';
 import AuthProvider from './contexts/AuthProvider';
-import CallProvider, { CallRole } from './contexts/CallProvider';
+import CallProvider, { CallRole, CallStatus } from './contexts/CallProvider';
 import ConversationProvider from './contexts/ConversationProvider';
 import WebRtcProvider from './contexts/WebRtcProvider';
 import WebSocketProvider from './contexts/WebSocketProvider';
@@ -37,12 +37,17 @@
 import { ThemeDemonstrator } from './themes/ThemeDemonstrator';
 
 export type ConversationRouteParams = RouteParams<{ conversationId: string }, Record<string, never>>;
+
 export type AddContactRouteParams = RouteParams<{ contactId: string }, Record<string, never>>;
 
-/**
- * Route parameters for the call routes.
- */
-export type CallRouteParams = RouteParams<{ conversationId: string }, { role?: CallRole }>;
+export type CallRouteParams = RouteParams<
+  { conversationId?: string },
+  { role?: CallRole },
+  {
+    isVideoOn?: boolean;
+    callStatus: CallStatus;
+  }
+>;
 
 export const router = createBrowserRouter(
   createRoutesFromElements(
@@ -64,7 +69,10 @@
       >
         <Route index element={<Messenger />} />
         <Route path="conversation" element={<Messenger />}>
-          <Route path="add-contact/:contactId" />
+          {/* TODO: Remove this route. Adding a contact should not change the route, we should instead use an internal
+                    state in the Messenger component
+           */}
+          <Route path="add-contact" element={<div></div>} />
           <Route
             path=":conversationId"
             element={