Improve behaviour of searching and adding contacts

- Styles are still supposed to be ugly
- Uniformize styles of list items for contacts and conversations
- Search field uses React Query for cache and get cleared properly
- Adding a contact updates the conversation list and closes the modal

Change-Id: I5949dff739a0f18fd39a89a744e1d26dcb36e2b2
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index 601f855..3ef3d94 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -351,7 +351,7 @@
   },
 }));
 
-const SquareButton = styled(({ Icon, ...props }: ShapedButtonProps) => (
+export const SquareButton = styled(({ Icon, ...props }: ShapedButtonProps) => (
   <IconButton {...props} disableRipple={true}>
     <Icon fontSize="inherit" />
   </IconButton>
diff --git a/client/src/components/ContactSearchResultList.tsx b/client/src/components/ContactSearchResultList.tsx
index cff4f6b..fbe988d 100644
--- a/client/src/components/ContactSearchResultList.tsx
+++ b/client/src/components/ContactSearchResultList.tsx
@@ -15,12 +15,15 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-
-import { Dialog, DialogProps, List, ListItem, ListItemAvatar, ListItemText } from '@mui/material';
+import { GroupAddRounded } from '@mui/icons-material';
+import { Box, Dialog, DialogProps, Fab, List, Typography } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
 
 import { Contact } from '../models/contact';
-import AddContactPage from '../pages/AddContactPage';
+import { useAddContactMutation } from '../services/contactQueries';
 import ConversationAvatar from './ConversationAvatar';
+import { CustomListItemButton } from './CustomListItemButton';
 import { useDialogHandler } from './Dialog';
 
 type ContactSearchResultListProps = {
@@ -47,19 +50,14 @@
   return (
     <>
       <AddContactDialog {...dialogHandler.props} contactId={contact.uri} />
-      <ListItem
-        button
-        alignItems="flex-start"
+      <CustomListItemButton
         key={contact.uri}
         onClick={() => {
           dialogHandler.openDialog();
         }}
-      >
-        <ListItemAvatar>
-          <ConversationAvatar />
-        </ListItemAvatar>
-        <ListItemText primary={contact.getDisplayName()} secondary={contact.uri} />
-      </ListItem>
+        icon={<ConversationAvatar displayName={contact.getDisplayName()} />}
+        primaryText={<Typography variant="body1">{contact.getDisplayName()}</Typography>}
+      />
     </>
   );
 };
@@ -69,9 +67,27 @@
 };
 
 const AddContactDialog = ({ contactId, ...props }: AddContactDialogProps) => {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+  const addContactMutation = useAddContactMutation();
+
+  const handleClick = async () => {
+    addContactMutation.mutate(contactId, {
+      onSuccess: (data) => navigate(`/conversation/${data.conversationId}`),
+      onSettled: () => props.onClose?.({}, 'escapeKeyDown'), // dummy arguments for 'onClose'
+    });
+  };
+
   return (
     <Dialog {...props}>
-      <AddContactPage contactId={contactId} />
+      <Typography variant="h6">{t('jami_user_id')}</Typography>
+      <Typography variant="body1">{contactId}</Typography>
+      <Box style={{ textAlign: 'center', marginTop: 16 }}>
+        <Fab variant="extended" color="primary" onClick={handleClick}>
+          <GroupAddRounded />
+          {t('conversation_add_contact')}
+        </Fab>
+      </Box>
     </Dialog>
   );
 };
diff --git a/client/src/components/ConversationList.tsx b/client/src/components/ConversationList.tsx
index 3f7603f..3320110 100644
--- a/client/src/components/ConversationList.tsx
+++ b/client/src/components/ConversationList.tsx
@@ -15,46 +15,74 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { GroupRounded as GroupIcon } from '@mui/icons-material';
+import { GroupRounded as GroupIcon, SearchRounded } from '@mui/icons-material';
+import { InputBase } from '@mui/material';
 import List from '@mui/material/List';
 import ListSubheader from '@mui/material/ListSubheader';
 import Typography from '@mui/material/Typography';
-import { useContext } from 'react';
+import { Stack } from '@mui/system';
+import { ChangeEvent, useCallback, useState } from 'react';
+import { useTranslation } from 'react-i18next';
 
-import { MessengerContext } from '../contexts/MessengerProvider';
+import { useContactsSearchQuery } from '../services/contactQueries';
+import { useConversationsSummariesQuery } from '../services/conversationQueries';
+import { SquareButton } from './Button';
 import ContactSearchResultList from './ContactSearchResultList';
 import ConversationListItem from './ConversationListItem';
 import LoadingPage from './Loading';
+import { PeopleGroupIcon } from './SvgIcon';
 
 export default function ConversationList() {
-  const { searchResult, conversationsSummaries } = useContext(MessengerContext);
+  const { t } = useTranslation();
+
+  const [searchFilter, setSearchFilter] = useState('');
+
+  const conversationsSummariesQuery = useConversationsSummariesQuery();
+  const contactsSearchQuery = useContactsSearchQuery(searchFilter);
+
+  const conversationsSummaries = conversationsSummariesQuery.data;
+  const contactsSearchResult = contactsSearchQuery.data;
+
+  const handleInputChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
+    setSearchFilter(event.target.value);
+  }, []);
 
   if (!conversationsSummaries) {
     return <LoadingPage />;
   }
 
   return (
-    <div className="rooms-list">
+    <Stack>
+      <Stack direction="row">
+        <InputBase
+          type="search"
+          placeholder={t('find_users_and_conversations')}
+          onChange={handleInputChange}
+          startAdornment={<SearchRounded />}
+          sx={{
+            flexGrow: 1,
+          }}
+        />
+        <SquareButton aria-label="start swarm" Icon={PeopleGroupIcon} />
+      </Stack>
       <List>
-        {searchResult && (
-          <div>
-            <ListSubheader>Public directory</ListSubheader>
-            <ContactSearchResultList contacts={searchResult} />
-            <ListSubheader>Conversations</ListSubheader>
-          </div>
+        {contactsSearchResult && contactsSearchResult.length > 0 && (
+          <>
+            <ListSubheader>{t('search_results')}</ListSubheader>
+            <ContactSearchResultList contacts={contactsSearchResult} />
+            <ListSubheader>{t('conversations')}</ListSubheader>
+          </>
         )}
         {conversationsSummaries.map((conversationSummary) => (
           <ConversationListItem key={conversationSummary.id} conversationSummary={conversationSummary} />
         ))}
         {conversationsSummaries.length === 0 && (
-          <div className="list-placeholder">
+          <Stack>
             <GroupIcon color="disabled" fontSize="large" />
-            <Typography className="subtitle" variant="subtitle2">
-              No conversation yet
-            </Typography>
-          </div>
+            <Typography variant="subtitle2">{t('no_conversations')}</Typography>
+          </Stack>
         )}
       </List>
-    </div>
+    </Stack>
   );
 }
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index cfe15d7..02e856d 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -15,7 +15,7 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Box, ListItemButton, Stack, Typography } from '@mui/material';
+import { Box, Stack, Typography } from '@mui/material';
 import dayjs from 'dayjs';
 import { IConversationSummary } from 'jami-web-common';
 import { QRCodeCanvas } from 'qrcode.react';
@@ -34,6 +34,7 @@
 import { formatRelativeDate, formatTime } from '../utils/dates&times';
 import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu';
 import ConversationAvatar from './ConversationAvatar';
+import { CustomListItemButton } from './CustomListItemButton';
 import { ConfirmationDialog, DialogContentList, InfosDialog, useDialogHandler } from './Dialog';
 import { PopoverListItemData } from './PopoverList';
 import { AudioCallIcon, CancelIcon, MessageIcon, PersonIcon, VideoCallIcon } from './SvgIcon';
@@ -48,9 +49,6 @@
     urlParams: { conversationId: selectedConversationId },
   } = useUrlParams<ConversationRouteParams>();
   const contextMenuHandler = useContextMenuHandler();
-  const callContext = useCallContext(true);
-  const { callData } = useContext(CallManagerContext);
-  const { t, i18n } = useTranslation();
   const navigate = useNavigate();
 
   const conversationId = conversationSummary.id;
@@ -62,6 +60,43 @@
     }
   }, [navigate, conversationId]);
 
+  const conversationName = useMemo(
+    () => conversationSummary.title || conversationSummary.membersNames.join(', ') || account.getDisplayName(),
+    [account, conversationSummary]
+  );
+
+  return (
+    <Box>
+      <ConversationMenu
+        conversationId={conversationId}
+        conversationName={conversationName}
+        onMessageClick={onClick}
+        isSelected={isSelected}
+        contextMenuProps={contextMenuHandler.props}
+      />
+      <CustomListItemButton
+        selected={isSelected}
+        onClick={onClick}
+        onContextMenu={contextMenuHandler.handleAnchorPosition}
+        icon={<ConversationAvatar displayName={conversationName} />}
+        primaryText={<Typography variant="body1">{conversationName}</Typography>}
+        secondaryText={<SecondaryText conversationSummary={conversationSummary} isSelected={isSelected} />}
+      />
+    </Box>
+  );
+}
+
+type SecondaryTextProps = {
+  conversationSummary: IConversationSummary;
+  isSelected: boolean;
+};
+
+const SecondaryText = ({ conversationSummary, isSelected }: SecondaryTextProps) => {
+  const { account } = useAuthContext();
+  const callContext = useCallContext(true);
+  const { callData } = useContext(CallManagerContext);
+  const { t, i18n } = useTranslation();
+
   const timeIndicator = useMemo(() => {
     const message = conversationSummary.lastMessage;
     const time = dayjs.unix(Number(message.timestamp));
@@ -110,42 +145,15 @@
     return callContext.callRole === 'caller' ? t('outgoing_call') : t('incoming_call');
   }, [account, conversationSummary, callContext, callData, t, i18n]);
 
-  const conversationName = useMemo(
-    () => conversationSummary.title || conversationSummary.membersNames.join(', ') || account.getDisplayName(),
-    [account, conversationSummary]
-  );
-
   return (
-    <Box>
-      <ConversationMenu
-        conversationId={conversationId}
-        conversationName={conversationName}
-        onMessageClick={onClick}
-        isSelected={isSelected}
-        contextMenuProps={contextMenuHandler.props}
-      />
-      <ListItemButton
-        alignItems="flex-start"
-        selected={isSelected}
-        onClick={onClick}
-        onContextMenu={contextMenuHandler.handleAnchorPosition}
-      >
-        <Stack direction="row" spacing="10px">
-          <ConversationAvatar displayName={conversationName} />
-          <Stack>
-            <Typography variant="body1">{conversationName}</Typography>
-            <Stack direction="row" spacing="5px">
-              <Typography variant="body2" fontWeight={isSelected ? 'bold' : 'normal'}>
-                {timeIndicator}
-              </Typography>
-              <Typography variant="body2">{lastMessageText}</Typography>
-            </Stack>
-          </Stack>
-        </Stack>
-      </ListItemButton>
-    </Box>
+    <Stack direction="row" spacing="5px">
+      <Typography variant="body2" fontWeight={isSelected ? 'bold' : 'normal'}>
+        {timeIndicator}
+      </Typography>
+      <Typography variant="body2">{lastMessageText}</Typography>
+    </Stack>
   );
-}
+};
 
 interface ConversationMenuProps {
   conversationId: string;
diff --git a/client/src/components/CustomListItemButton.tsx b/client/src/components/CustomListItemButton.tsx
new file mode 100644
index 0000000..a4c4556
--- /dev/null
+++ b/client/src/components/CustomListItemButton.tsx
@@ -0,0 +1,42 @@
+/*
+ * 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 { ListItemButton, ListItemButtonProps, Stack } from '@mui/material';
+import { ReactNode } from 'react';
+
+type CustomListItemButtonProps = ListItemButtonProps & {
+  icon: ReactNode;
+  primaryText: ReactNode;
+  secondaryText?: ReactNode;
+};
+
+// The spacings between the elements of ListItemButton have been too hard to customize in the theme,
+// plus 'primary' and 'secondary' props on ListItemText do not accept block elements such as div.
+// This component exists in order to keep consistency in lists nevertheless
+export const CustomListItemButton = ({ icon, primaryText, secondaryText, ...props }: CustomListItemButtonProps) => {
+  return (
+    <ListItemButton alignItems="flex-start" {...props}>
+      <Stack direction="row" spacing="10px">
+        {icon}
+        <Stack justifyContent="center">
+          {primaryText}
+          {secondaryText}
+        </Stack>
+      </Stack>
+    </ListItemButton>
+  );
+};
diff --git a/client/src/components/NewContactForm.tsx b/client/src/components/NewContactForm.tsx
deleted file mode 100644
index d3e328b..0000000
--- a/client/src/components/NewContactForm.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
- * 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 { SearchRounded } from '@mui/icons-material';
-import { InputAdornment, InputBase } from '@mui/material';
-import { ChangeEvent, useContext, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-
-import { MessengerContext } from '../contexts/MessengerProvider';
-
-export default function NewContactForm() {
-  const { setSearchQuery } = useContext(MessengerContext);
-  const [value, setValue] = useState('');
-  const { t } = useTranslation();
-
-  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
-    setValue(event.target.value);
-    setSearchQuery(event.target.value);
-  };
-
-  return (
-    <div className="main-search">
-      <InputBase
-        className="main-search-input"
-        type="search"
-        placeholder={t('conversation_add_contact_form')}
-        value={value}
-        onChange={handleChange}
-        startAdornment={
-          <InputAdornment position="start">
-            <SearchRounded />
-          </InputAdornment>
-        }
-      />
-    </div>
-  );
-}
diff --git a/client/src/components/SvgIcon.tsx b/client/src/components/SvgIcon.tsx
index 0a0e6bb..2d9ec16 100644
--- a/client/src/components/SvgIcon.tsx
+++ b/client/src/components/SvgIcon.tsx
@@ -528,6 +528,14 @@
   );
 };
 
+export const PeopleGroupIcon = (props: SvgIconProps) => {
+  return (
+    <SvgIcon {...props} viewBox="0 0 24 24">
+      <path d="M20 8.4c-.4-.3-.8-.5-1.2-.7.2-.2.4-.5.6-.8.4-.8.4-1.8 0-2.6-.5-.9-1.5-1.6-2.6-1.6-.7 0-1.5.3-2 .8-.6.6-1 1.6-.8 2.5.1.6.4 1.2.8 1.6-.3.1-.6.3-.9.5-.5-.4-1.2-.6-1.9-.6-.6 0-1.3.2-1.8.6-.3-.1-.7-.2-1-.4.2-.2.4-.5.6-.8.4-.8.4-1.7 0-2.5-.5-1-1.5-1.6-2.6-1.6-.7 0-1.4.3-1.9.8-.7.6-1 1.6-.9 2.5.1.6.4 1.2.8 1.6-1.8.7-3.1 2.5-3.4 4.4v1c0 .2 0 .2.1.3.1.2.4.3.6.3.3 0 .5-.2.5-.5v-.4c0-1.4.7-2.7 1.8-3.5 1.3-.9 3-1 4.4-.2 0 .1-.1.3-.2.4-.4.9-.5 2 0 3 .2.4.5.8.8 1.1-1.2.5-2.3 1.3-3 2.3-.8 1.1-1.2 2.4-1.2 3.7v.1c0 .2 0 .4.1.6.1.2.4.3.6.3.3 0 .5-.2.5-.5v-.5c0-1.5.6-2.9 1.7-3.9 1.4-1.3 3.6-1.7 5.4-1 1.7.7 2.9 2.3 3.2 4 .1.4.1.8.1 1.2v.1c0 .1.1.2.2.3.1.1.2.2.4.2h.2c.2-.1.4-.3.4-.6v-.4c0-1.4-.4-2.7-1.2-3.8-.7-1-1.8-1.8-3-2.2.3-.3.6-.7.8-1.1.4-.9.4-2 0-3-.1-.1-.1-.3-.2-.4 1.1-.6 2.4-.7 3.6-.3 1.4.5 2.4 1.9 2.6 3.4v.9c0 .2 0 .2.1.3.1.2.4.3.6.3.3 0 .5-.2.5-.5v-.4c0-1.6-.8-3.2-2.2-4.3zM8.8 5.9c-.1.6-.5 1.1-1 1.3-.6.3-1.3.1-1.7-.3-.4-.4-.6-1-.5-1.5.2-.8.9-1.4 1.6-1.4h.1c.4 0 .8.2 1.1.6.3.4.5.9.4 1.3zm5.3 5.4c-.1.7-.6 1.4-1.3 1.7-.7.3-1.7.1-2.3-.4-.5-.6-.7-1.3-.6-2 .2-.9 1.1-1.7 2-1.7.3 0 .6.1.9.2.9.4 1.4 1.3 1.3 2.2zm2.7-7.5V4c.7 0 1.4.6 1.6 1.3.1.7-.3 1.5-1 1.8-.6.2-1.3.1-1.8-.3-.4-.4-.6-1-.5-1.5.3-.7.9-1.3 1.7-1.3v-.2z" />
+    </SvgIcon>
+  );
+};
+
 export const PeopleWithPlusSignIcon = (props: SvgIconProps) => {
   return (
     <SvgIcon {...props} viewBox="2 2 20 20">