Fix add contact and create MessengerProvider

Move all messenger logic and state from `Messenger.tsx` to a new context `MessengerProvider.tsx`.
Improve add contact logic to use a state in `MessengerContext` for the newContactId instead of a URL parameter. Remove `add-contact` route.
Improve messenger routing.
Fix remove contact.

GitLab: #171
Change-Id: Iea641deb12dbd339d03eff683b41834123a516ab
diff --git a/client/src/components/ConversationList.tsx b/client/src/components/ConversationList.tsx
index 316792b..684c7e5 100644
--- a/client/src/components/ConversationList.tsx
+++ b/client/src/components/ConversationList.tsx
@@ -20,17 +20,17 @@
 import ListSubheader from '@mui/material/ListSubheader';
 import Typography from '@mui/material/Typography';
 import { Conversation } from 'jami-web-common';
-import { useEffect } from 'react';
+import { useContext, useEffect } from 'react';
 
+import { MessengerContext } from '../contexts/MessengerProvider';
 import { useAppSelector } from '../redux/hooks';
 import ConversationListItem from './ConversationListItem';
 
 type ConversationListProps = {
-  accountId: string;
   conversations: Conversation[];
-  search?: Conversation;
 };
-export default function ConversationList(props: ConversationListProps) {
+export default function ConversationList({ conversations }: ConversationListProps) {
+  const { searchResult } = useContext(MessengerContext);
   const { refresh } = useAppSelector((state) => state.userInfo);
 
   useEffect(() => {
@@ -40,17 +40,17 @@
   return (
     <div className="rooms-list">
       <List>
-        {props.search instanceof Conversation && (
+        {searchResult && (
           <div>
             <ListSubheader>Public directory</ListSubheader>
-            <ConversationListItem conversation={props.search} />
+            <ConversationListItem conversation={searchResult} />
             <ListSubheader>Conversations</ListSubheader>
           </div>
         )}
-        {props.conversations.map((conversation) => (
+        {conversations.map((conversation) => (
           <ConversationListItem key={conversation.getId()} conversation={conversation} />
         ))}
-        {props.conversations.length === 0 && (
+        {conversations.length === 0 && (
           <div className="list-placeholder">
             <GroupIcon color="disabled" fontSize="large" />
             <Typography className="subtitle" variant="subtitle2">
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index 48e9004..03685c3 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -18,14 +18,17 @@
 import { Box, ListItem, ListItemAvatar, ListItemText } from '@mui/material';
 import { Conversation } from 'jami-web-common';
 import { QRCodeCanvas } from 'qrcode.react';
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useContext, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useNavigate } from 'react-router-dom';
 
 import { useAuthContext } from '../contexts/AuthProvider';
+import { MessengerContext } from '../contexts/MessengerProvider';
 import { useStartCall } from '../hooks/useStartCall';
+import { useUrlParams } from '../hooks/useUrlParams';
 import { setRefreshFromSlice } from '../redux/appSlice';
 import { useAppDispatch } from '../redux/hooks';
+import { ConversationRouteParams } from '../router';
 import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu';
 import ConversationAvatar from './ConversationAvatar';
 import { ConfirmationDialog, DialogContentList, InfosDialog, useDialogHandler } from './Dialog';
@@ -45,29 +48,37 @@
 };
 
 export default function ConversationListItem({ conversation }: ConversationListItemProps) {
-  const { conversationId, contactId } = useParams();
+  const {
+    urlParams: { conversationId },
+  } = useUrlParams<ConversationRouteParams>();
   const contextMenuHandler = useContextMenuHandler();
+  const { newContactId, setNewContactId } = useContext(MessengerContext);
 
-  const pathId = conversationId || contactId;
+  const pathId = conversationId || newContactId;
   const isSelected = conversation.getDisplayUri() === pathId;
+
   const navigate = useNavigate();
   const userId = conversation?.getFirstMember()?.contact.getUri();
 
-  // 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}`;
+  const onClick = useCallback(() => {
+    const newConversationId = conversation.getId();
+    if (newConversationId) {
+      navigate(`/conversation/${newConversationId}`);
+    } else {
+      setNewContactId(userId);
+    }
+  }, [navigate, conversation, userId, setNewContactId]);
+
   return (
     <Box onContextMenu={contextMenuHandler.handleAnchorPosition}>
       <ConversationMenu
         userId={userId}
         conversation={conversation}
-        uri={uri}
+        onMessageClick={onClick}
         isSelected={isSelected}
         contextMenuProps={contextMenuHandler.props}
       />
-      <ListItem button alignItems="flex-start" selected={isSelected} onClick={() => navigate(uri)}>
+      <ListItem button alignItems="flex-start" selected={isSelected} onClick={onClick}>
         <ListItemAvatar>
           <ConversationAvatar displayName={conversation.getDisplayNameNoFallback()} />
         </ListItemAvatar>
@@ -80,12 +91,18 @@
 interface ConversationMenuProps {
   userId: string;
   conversation: Conversation;
-  uri: string;
+  onMessageClick: () => void;
   isSelected: boolean;
   contextMenuProps: ContextMenuHandler['props'];
 }
 
-const ConversationMenu = ({ userId, conversation, uri, isSelected, contextMenuProps }: ConversationMenuProps) => {
+const ConversationMenu = ({
+  userId,
+  conversation,
+  onMessageClick,
+  isSelected,
+  contextMenuProps,
+}: ConversationMenuProps) => {
   const { t } = useTranslation();
   const { axiosInstance } = useAuthContext();
   const [isSwarm] = useState(true);
@@ -117,9 +134,7 @@
       {
         label: t('conversation_message'),
         Icon: MessageIcon,
-        onClick: () => {
-          navigate(uri);
-        },
+        onClick: onMessageClick,
       },
       {
         label: t('conversation_start_audiocall'),
@@ -177,7 +192,7 @@
     ],
     [
       navigate,
-      uri,
+      onMessageClick,
       isSelected,
       getContactDetails,
       detailsDialogHandler,
@@ -302,7 +317,7 @@
   const remove = async () => {
     const controller = new AbortController();
     try {
-      await axiosInstance.delete(`/contacts/${userId}/remove`, {
+      await axiosInstance.delete(`/contacts/${userId}`, {
         signal: controller.signal,
       });
       dispatch(setRefreshFromSlice());
diff --git a/client/src/components/NewContactForm.tsx b/client/src/components/NewContactForm.tsx
index d55b80b..21ead27 100644
--- a/client/src/components/NewContactForm.tsx
+++ b/client/src/components/NewContactForm.tsx
@@ -17,28 +17,21 @@
  */
 import { SearchRounded } from '@mui/icons-material';
 import { InputAdornment, InputBase } from '@mui/material';
-import { ChangeEvent, FormEvent, useState } from 'react';
+import { ChangeEvent, useContext, useState } from 'react';
 
-type NewContactFormProps = {
-  onChange?: (v: string) => void;
-  onSubmit?: (v: string) => void;
-};
+import { MessengerContext } from '../contexts/MessengerProvider';
 
-export default function NewContactForm(props: NewContactFormProps) {
+export default function NewContactForm() {
+  const { setSearchQuery } = useContext(MessengerContext);
   const [value, setValue] = useState('');
 
   const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
     setValue(event.target.value);
-    if (props.onChange) props.onChange(event.target.value);
-  };
-
-  const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
-    event.preventDefault();
-    if (value && props.onSubmit) props.onSubmit(value);
+    setSearchQuery(event.target.value);
   };
 
   return (
-    <form className="main-search" onSubmit={handleSubmit} noValidate autoComplete="off">
+    <form className="main-search" noValidate autoComplete="off">
       <InputBase
         className="main-search-input"
         type="search"
diff --git a/client/src/contexts/MessengerProvider.tsx b/client/src/contexts/MessengerProvider.tsx
new file mode 100644
index 0000000..f92a6d7
--- /dev/null
+++ b/client/src/contexts/MessengerProvider.tsx
@@ -0,0 +1,119 @@
+/*
+ * 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 { Contact, Conversation, ConversationMessage, WebSocketMessageType } from 'jami-web-common';
+import React, { createContext, ReactNode, useContext, useEffect, useState } from 'react';
+
+import { setRefreshFromSlice } from '../redux/appSlice';
+import { useAppDispatch, useAppSelector } from '../redux/hooks';
+import { SetState } from '../utils/utils';
+import { useAuthContext } from './AuthProvider';
+import { WebSocketContext } from './WebSocketProvider';
+
+export interface IMessengerContext {
+  conversations: Conversation[] | undefined;
+
+  setSearchQuery: SetState<string | undefined>;
+
+  searchResult: Conversation | undefined;
+
+  newContactId: string | undefined;
+  setNewContactId: SetState<string | undefined>;
+}
+
+const defaultMessengerContext: IMessengerContext = {
+  conversations: undefined,
+  newContactId: undefined,
+  setNewContactId: () => {},
+  setSearchQuery: () => {},
+  searchResult: undefined,
+};
+
+export const MessengerContext = createContext<IMessengerContext>(defaultMessengerContext);
+
+export default ({ children }: { children: ReactNode }) => {
+  const { refresh } = useAppSelector((state) => state.userInfo);
+  const dispatch = useAppDispatch();
+  const { accountId, axiosInstance } = useAuthContext();
+  const webSocket = useContext(WebSocketContext);
+
+  const [conversations, setConversations] = useState<Conversation[] | undefined>(undefined);
+  const [searchQuery, setSearchQuery] = useState<string>();
+  const [searchResult, setSearchResults] = useState<Conversation | undefined>(undefined);
+  const [newContactId, setNewContactId] = useState<string>();
+
+  useEffect(() => {
+    const controller = new AbortController();
+    axiosInstance
+      .get<Conversation[]>('/conversations', {
+        signal: controller.signal,
+      })
+      .then(({ data }) => {
+        setConversations(Object.values(data).map((c) => Conversation.from(accountId, c)));
+      });
+    // return () => controller.abort()
+  }, [axiosInstance, accountId, refresh]);
+
+  useEffect(() => {
+    if (!webSocket) {
+      return;
+    }
+
+    const conversationMessageListener = (_data: ConversationMessage) => {
+      dispatch(setRefreshFromSlice());
+    };
+
+    webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
+
+    return () => {
+      webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
+    };
+  }, [webSocket, dispatch]);
+
+  useEffect(() => {
+    if (!searchQuery) return;
+    const controller = new AbortController();
+    // TODO: Type properly https://git.jami.net/savoirfairelinux/jami-web/-/issues/92
+    axiosInstance
+      .get<{ state: number; address: string; username: string }>(`/ns/username/${searchQuery}`, {
+        signal: controller.signal,
+      })
+      .then(({ data }) => {
+        const contact = new Contact(data.address);
+        contact.setRegisteredName(data.username);
+        setSearchResults(contact ? Conversation.fromSingleContact(accountId, contact) : undefined);
+      })
+      .catch(() => {
+        setSearchResults(undefined);
+      });
+    // return () => controller.abort() // crash on React18
+  }, [accountId, searchQuery, axiosInstance]);
+
+  return (
+    <MessengerContext.Provider
+      value={{
+        conversations,
+        setSearchQuery,
+        searchResult,
+        newContactId,
+        setNewContactId,
+      }}
+    >
+      {children}
+    </MessengerContext.Provider>
+  );
+};
diff --git a/client/src/pages/Messenger.tsx b/client/src/pages/Messenger.tsx
index 7f10e41..294e069 100644
--- a/client/src/pages/Messenger.tsx
+++ b/client/src/pages/Messenger.tsx
@@ -16,96 +16,27 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Box, Stack } from '@mui/material';
-import { Contact, Conversation, ConversationMessage, WebSocketMessageType } from 'jami-web-common';
-import { useContext, useEffect, useState } from 'react';
-import { Outlet } from 'react-router-dom';
+import { ReactNode, useContext } from 'react';
 
 //import Sound from 'react-sound';
 import ConversationList from '../components/ConversationList';
 import Header from '../components/Header';
 import LoadingPage from '../components/Loading';
 import NewContactForm from '../components/NewContactForm';
-import { useAuthContext } from '../contexts/AuthProvider';
-import { WebSocketContext } from '../contexts/WebSocketProvider';
-import { useUrlParams } from '../hooks/useUrlParams';
-import { setRefreshFromSlice } from '../redux/appSlice';
-import { useAppDispatch, useAppSelector } from '../redux/hooks';
-import { AddContactRouteParams } from '../router';
+import { MessengerContext } from '../contexts/MessengerProvider';
 import AddContactPage from './AddContactPage';
 
-const Messenger = () => {
-  const { refresh } = useAppSelector((state) => state.userInfo);
-  const dispatch = useAppDispatch();
-  const { account, axiosInstance } = useAuthContext();
-  const webSocket = useContext(WebSocketContext);
-
-  const [conversations, setConversations] = useState<Conversation[] | undefined>(undefined);
-  const [searchQuery, setSearchQuery] = useState('');
-  const [searchResult, setSearchResults] = useState<Conversation | undefined>(undefined);
-
-  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();
-
-  useEffect(() => {
-    const controller = new AbortController();
-    axiosInstance
-      .get<Conversation[]>('/conversations', {
-        signal: controller.signal,
-      })
-      .then(({ data }) => {
-        setConversations(Object.values(data).map((c) => Conversation.from(accountId, c)));
-      });
-    // return () => controller.abort()
-  }, [axiosInstance, accountId, refresh]);
-
-  useEffect(() => {
-    if (!webSocket) {
-      return;
-    }
-
-    const conversationMessageListener = (_data: ConversationMessage) => {
-      dispatch(setRefreshFromSlice());
-    };
-
-    webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
-
-    return () => {
-      webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
-    };
-  }, [webSocket, dispatch]);
-
-  useEffect(() => {
-    if (!searchQuery) return;
-    const controller = new AbortController();
-    // TODO: Type properly https://git.jami.net/savoirfairelinux/jami-web/-/issues/92
-    axiosInstance
-      .get<{ state: number; address: string; username: string }>(`/ns/username/${searchQuery}`, {
-        signal: controller.signal,
-      })
-      .then(({ data }) => {
-        const contact = new Contact(data.address);
-        contact.setRegisteredName(data.username);
-        setSearchResults(contact ? Conversation.fromSingleContact(accountId, contact) : undefined);
-      })
-      .catch(() => {
-        setSearchResults(undefined);
-      });
-    // return () => controller.abort() // crash on React18
-  }, [accountId, searchQuery, axiosInstance]);
+const Messenger = ({ children }: { children?: ReactNode }) => {
+  const { newContactId, conversations } = useContext(MessengerContext);
 
   return (
     <Box display="flex" height="100%">
       <Stack flexGrow={0} flexShrink={0} overflow="auto">
         <Header />
-        <NewContactForm onChange={setSearchQuery} />
+        <NewContactForm />
         {newContactId && <AddContactPage contactId={newContactId} />}
         {conversations ? (
-          <ConversationList search={searchResult} conversations={conversations} accountId={accountId} />
+          <ConversationList conversations={conversations} />
         ) : (
           <div className="rooms-list">
             <LoadingPage />
@@ -113,7 +44,7 @@
         )}
       </Stack>
       <Box flexGrow={1} display="flex" position="relative">
-        <Outlet />
+        {children}
       </Box>
     </Box>
   );
diff --git a/client/src/router.tsx b/client/src/router.tsx
index 5b6b71d..714adde 100644
--- a/client/src/router.tsx
+++ b/client/src/router.tsx
@@ -23,6 +23,7 @@
 import AuthProvider from './contexts/AuthProvider';
 import CallProvider, { CallRole, CallStatus } from './contexts/CallProvider';
 import ConversationProvider from './contexts/ConversationProvider';
+import MessengerProvider from './contexts/MessengerProvider';
 import WebRtcProvider from './contexts/WebRtcProvider';
 import WebSocketProvider from './contexts/WebSocketProvider';
 import { RouteParams } from './hooks/useUrlParams';
@@ -36,9 +37,7 @@
 import Welcome from './pages/Welcome';
 import { ThemeDemonstrator } from './themes/ThemeDemonstrator';
 
-export type ConversationRouteParams = RouteParams<{ conversationId: string }, Record<string, never>>;
-
-export type AddContactRouteParams = RouteParams<{ contactId: string }, Record<string, never>>;
+export type ConversationRouteParams = RouteParams<{ conversationId?: string }, Record<string, never>>;
 
 export type CallRouteParams = RouteParams<
   { conversationId?: string },
@@ -67,18 +66,23 @@
           </AuthProvider>
         }
       >
-        <Route index element={<Messenger />} />
-        <Route path="conversation" element={<Messenger />}>
-          {/* 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
+          element={
+            <MessengerProvider>
+              <Outlet />
+            </MessengerProvider>
+          }
+        >
+          <Route index element={<Messenger />} />
+          <Route path="conversation" element={<Messenger />} />
           <Route
-            path=":conversationId"
+            path="conversation/:conversationId"
             element={
-              <ConversationProvider>
-                <Outlet />
-              </ConversationProvider>
+              <Messenger>
+                <ConversationProvider>
+                  <Outlet />
+                </ConversationProvider>
+              </Messenger>
             }
           >
             <Route index element={<ConversationView />} />