Add conversation requests list

- Add routes to REST API for conversation requests
- Add websocket notification on new conversation requests. This is unreliable.
- Rename 'ColoredCallButton' as 'ColoredRoundButton' and move it to Buttons file for reuse
- Review logic to show conversation tabs
- Add useConversationDisplayNameShort for conversations' names in lists. Will need more work.
- Add hooks to help managing React Query's cache
- Use React Query to remove conversations and update the cache doing so.
- Add ContactService and ConversationService as a way to group reusable functions for the server. This is inspired by jami-android

Known bug: The server often freezes on getContactFromUri (in ContactService) when a new conversation request is received.

Change-Id: I46a60a401f09c3941c864afcdb2625b5fcfe054a
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index 3ef3d94..92ef37d 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -31,8 +31,9 @@
   RadioGroup,
   RadioGroupProps,
   SvgIconProps,
+  Theme,
 } from '@mui/material';
-import { styled } from '@mui/material/styles';
+import { PaletteColor, styled } from '@mui/material/styles';
 import EmojiPicker, { IEmojiData } from 'emoji-picker-react';
 import { ComponentType, MouseEvent, ReactNode, useCallback, useState } from 'react';
 
@@ -508,3 +509,34 @@
   color: 'white',
   fontSize: '10px',
 });
+
+export const ColoredRoundButton = styled(
+  ({
+    paletteColor,
+    Icon,
+    ...props
+  }: ShapedButtonProps & {
+    paletteColor?: PaletteColor | ((theme: Theme) => PaletteColor);
+  }) => {
+    return (
+      <IconButton {...props} disableRipple={true}>
+        <Icon fontSize="inherit" />
+      </IconButton>
+    );
+  }
+)(({ theme, paletteColor = theme.palette.primary }) => {
+  if (typeof paletteColor === 'function') {
+    paletteColor = paletteColor(theme);
+  }
+
+  return {
+    color: paletteColor.contrastText,
+    backgroundColor: paletteColor.dark,
+    '&:hover': {
+      backgroundColor: paletteColor.main,
+    },
+    '&:disabled': {
+      backgroundColor: theme.palette.action.disabled,
+    },
+  };
+});
diff --git a/client/src/components/CallButtons.tsx b/client/src/components/CallButtons.tsx
index 9243c33..0b179bd 100644
--- a/client/src/components/CallButtons.tsx
+++ b/client/src/components/CallButtons.tsx
@@ -16,16 +16,16 @@
  * <https://www.gnu.org/licenses/>.
  */
 
-import { IconButton, IconButtonProps, PaletteColor } from '@mui/material';
-import { styled, Theme } from '@mui/material/styles';
+import { IconButtonProps } from '@mui/material';
+import { styled } from '@mui/material/styles';
 import { ChangeEvent, useMemo } from 'react';
 
 import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
 import {
+  ColoredRoundButton,
   ExpandableButton,
   ExpandableButtonProps,
   ExpandMenuRadioOption,
-  ShapedButtonProps,
   ToggleIconButton,
 } from './Button';
 import {
@@ -56,37 +56,6 @@
   },
 });
 
-const ColoredCallButton = styled(
-  ({
-    paletteColor,
-    Icon,
-    ...props
-  }: ShapedButtonProps & {
-    paletteColor?: PaletteColor | ((theme: Theme) => PaletteColor);
-  }) => {
-    return (
-      <IconButton {...props} disableRipple={true}>
-        <Icon fontSize="inherit" />
-      </IconButton>
-    );
-  }
-)(({ theme, paletteColor = theme.palette.primary }) => {
-  if (typeof paletteColor === 'function') {
-    paletteColor = paletteColor(theme);
-  }
-
-  return {
-    color: paletteColor.contrastText,
-    backgroundColor: paletteColor.dark,
-    '&:hover': {
-      backgroundColor: paletteColor.main,
-    },
-    '&:disabled': {
-      backgroundColor: theme.palette.action.disabled,
-    },
-  };
-});
-
 export const CallingChatButton = (props: ExpandableButtonProps) => {
   const { setIsChatShown } = useCallContext();
   return (
@@ -104,7 +73,7 @@
 export const CallingEndButton = (props: ExpandableButtonProps) => {
   const { endCall } = useCallContext();
   return (
-    <ColoredCallButton
+    <ColoredRoundButton
       paletteColor={(theme) => theme.palette.error}
       onClick={() => {
         endCall();
@@ -269,7 +238,7 @@
   const { endCall } = useCallContext();
 
   return (
-    <ColoredCallButton
+    <ColoredRoundButton
       aria-label="cancel call"
       onClick={() => {
         endCall();
@@ -285,7 +254,7 @@
   const { acceptCall, callStatus } = useCallContext();
 
   return (
-    <ColoredCallButton
+    <ColoredRoundButton
       disabled={callStatus === CallStatus.Loading || callStatus === CallStatus.Connecting}
       aria-label="answer call audio"
       onClick={() => {
@@ -301,7 +270,7 @@
 export const CallingAnswerVideoButton = (props: IconButtonProps) => {
   const { acceptCall, callStatus } = useCallContext();
   return (
-    <ColoredCallButton
+    <ColoredRoundButton
       disabled={callStatus === CallStatus.Connecting || callStatus === CallStatus.Loading}
       aria-label="answer call video"
       onClick={() => {
@@ -317,7 +286,7 @@
 export const CallingRefuseButton = (props: IconButtonProps) => {
   const { endCall } = useCallContext();
   return (
-    <ColoredCallButton
+    <ColoredRoundButton
       aria-label="refuse call"
       onClick={() => {
         endCall();
diff --git a/client/src/components/ConversationList.tsx b/client/src/components/ConversationList.tsx
index 2792fd4..1ce22dc 100644
--- a/client/src/components/ConversationList.tsx
+++ b/client/src/components/ConversationList.tsx
@@ -17,15 +17,16 @@
  */
 import { SearchRounded } from '@mui/icons-material';
 import { InputBase, List, Stack } from '@mui/material';
-import { ChangeEvent, useCallback, useState } from 'react';
+import { ChangeEvent, useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import { useConversationsSummariesQuery } from '../services/conversationQueries';
+import { useConversationRequestsQuery, useConversationsSummariesQuery } from '../services/conversationQueries';
 import { SquareButton } from './Button';
 import ContactSearchResultList from './ContactSearchResultList';
 import ConversationListItem from './ConversationListItem';
+import { ConversationRequestList } from './ConversationRequestList';
 import { PeopleGroupIcon } from './SvgIcon';
-import { Tab, TabPanel, Tabs, TabsList } from './Tabs';
+import { Tab, TabPanel, TabPanelProps, Tabs, TabsList, TabsProps } from './Tabs';
 
 export default function ConversationList() {
   const { t } = useTranslation();
@@ -55,29 +56,51 @@
 }
 
 const ConversationTabs = () => {
+  const conversationsTabIndex = 0;
+  const invitationsTabIndex = 1;
   const { t } = useTranslation();
-  const invitations = [];
+  const conversationRequestsQuery = useConversationRequestsQuery();
+  const conversationRequestsLength = conversationRequestsQuery.data?.length;
+
+  const [isShowingTabMenu, setIsShowingTabMenu] = useState(false);
+  const [tabIndex, setTabIndex] = useState(conversationsTabIndex);
+
+  useEffect(() => {
+    const newIsShowingTabMenu = !!conversationRequestsLength;
+    setIsShowingTabMenu(newIsShowingTabMenu);
+    if (!newIsShowingTabMenu) {
+      setTabIndex(conversationsTabIndex);
+    }
+  }, [conversationRequestsLength]);
+
+  const onChange = useCallback<NonNullable<TabsProps['onChange']>>((_event, value) => {
+    if (typeof value === 'number') {
+      setTabIndex(value);
+    }
+  }, []);
 
   return (
-    <Tabs defaultValue={0}>
-      {invitations.length !== 0 && (
+    <Tabs defaultValue={conversationsTabIndex} value={tabIndex} onChange={onChange}>
+      {isShowingTabMenu && (
         <TabsList>
           <Tab>{t('conversations')}</Tab>
-          <Tab>{t('invitations')}</Tab>
+          <Tab>
+            {t('invitations')} {conversationRequestsLength}
+          </Tab>
         </TabsList>
       )}
-      <ConversationsTabPanel />
-      <InvitationsTabPanel />
+      <ConversationsTabPanel value={conversationsTabIndex} />
+      <InvitationsTabPanel value={invitationsTabIndex} />
     </Tabs>
   );
 };
 
-const ConversationsTabPanel = () => {
+const ConversationsTabPanel = (props: TabPanelProps) => {
   const conversationsSummariesQuery = useConversationsSummariesQuery();
   const conversationsSummaries = conversationsSummariesQuery.data;
 
   return (
-    <TabPanel value={0}>
+    <TabPanel {...props}>
       <List>
         {conversationsSummaries?.map((conversationSummary) => (
           <ConversationListItem key={conversationSummary.id} conversationSummary={conversationSummary} />
@@ -87,8 +110,10 @@
   );
 };
 
-const InvitationsTabPanel = () => {
-  const { t } = useTranslation();
-
-  return <TabPanel value={1}>{t('invitations')}</TabPanel>;
+const InvitationsTabPanel = (props: TabPanelProps) => {
+  return (
+    <TabPanel {...props}>
+      <ConversationRequestList />
+    </TabPanel>
+  );
 };
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index 02e856d..e3803a2 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -26,10 +26,10 @@
 import { useAuthContext } from '../contexts/AuthProvider';
 import { CallManagerContext } from '../contexts/CallManagerProvider';
 import { CallStatus, useCallContext } from '../contexts/CallProvider';
+import { useConversationDisplayNameShort } from '../hooks/useConversationDisplayName';
 import { useUrlParams } from '../hooks/useUrlParams';
-import { setRefreshFromSlice } from '../redux/appSlice';
-import { useAppDispatch } from '../redux/hooks';
 import { ConversationRouteParams } from '../router';
+import { useRemoveConversationMutation } from '../services/conversationQueries';
 import { getMessageCallText, getMessageMemberText } from '../utils/chatmessages';
 import { formatRelativeDate, formatTime } from '../utils/dates&times';
 import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu';
@@ -60,9 +60,10 @@
     }
   }, [navigate, conversationId]);
 
-  const conversationName = useMemo(
-    () => conversationSummary.title || conversationSummary.membersNames.join(', ') || account.getDisplayName(),
-    [account, conversationSummary]
+  const conversationName = useConversationDisplayNameShort(
+    account,
+    conversationSummary.title,
+    conversationSummary.membersNames
   );
 
   return (
@@ -78,7 +79,7 @@
         selected={isSelected}
         onClick={onClick}
         onContextMenu={contextMenuHandler.handleAnchorPosition}
-        icon={<ConversationAvatar displayName={conversationName} />}
+        icon={<ConversationAvatar displayName={conversationName} src={conversationSummary.avatar} />}
         primaryText={<Typography variant="body1">{conversationName}</Typography>}
         secondaryText={<SecondaryText conversationSummary={conversationSummary} isSelected={isSelected} />}
       />
@@ -260,7 +261,11 @@
         isSwarm={isSwarm}
       />
 
-      <RemoveConversationDialog {...RemoveConversationDialogHandler.props} conversationId={conversationId} />
+      <RemoveConversationDialog
+        {...RemoveConversationDialogHandler.props}
+        conversationId={conversationId}
+        isSelected={isSelected}
+      />
     </>
   );
 };
@@ -309,28 +314,34 @@
 
 interface RemoveConversationDialogProps {
   conversationId: string;
+  isSelected: boolean;
   open: boolean;
   onClose: () => void;
 }
 
-const RemoveConversationDialog = ({ conversationId, open, onClose }: RemoveConversationDialogProps) => {
-  const { axiosInstance } = useAuthContext();
+const RemoveConversationDialog = ({ conversationId, isSelected, open, onClose }: RemoveConversationDialogProps) => {
   const { t } = useTranslation();
-  const dispatch = useAppDispatch();
+  const navigate = useNavigate();
+  const removeConversationMutation = useRemoveConversationMutation();
 
-  const remove = async () => {
-    const controller = new AbortController();
-    try {
-      await axiosInstance.delete(`/conversations/${conversationId}`, {
-        signal: controller.signal,
-      });
-      dispatch(setRefreshFromSlice());
-    } catch (e) {
-      console.error(`Error removing conversation : `, e);
-      dispatch(setRefreshFromSlice());
-    }
-    onClose();
-  };
+  const remove = useCallback(async () => {
+    removeConversationMutation.mutate(
+      { conversationId },
+      {
+        onSuccess: () => {
+          if (isSelected) {
+            navigate('/conversation/');
+          }
+        },
+        onError: (e) => {
+          console.error(`Error removing conversation : `, e);
+        },
+        onSettled: () => {
+          onClose();
+        },
+      }
+    );
+  }, [conversationId, isSelected, navigate, onClose, removeConversationMutation]);
 
   return (
     <ConfirmationDialog
diff --git a/client/src/components/ConversationRequestList.tsx b/client/src/components/ConversationRequestList.tsx
new file mode 100644
index 0000000..80bd739
--- /dev/null
+++ b/client/src/components/ConversationRequestList.tsx
@@ -0,0 +1,179 @@
+/*
+ * 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 { Dialog, DialogProps, List, Stack, Typography } from '@mui/material';
+import { IConversationRequest } from 'jami-web-common';
+import { useCallback, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useNavigate } from 'react-router-dom';
+
+import { useConversationDisplayNameShort } from '../hooks/useConversationDisplayName';
+import { Contact } from '../models/contact';
+import {
+  useAcceptConversationRequestMutation,
+  useBlockConversationRequestMutation,
+  useConversationRequestsQuery,
+  useDeclineConversationRequestMutation,
+} from '../services/conversationQueries';
+import { ColoredRoundButton } from './Button';
+import ConversationAvatar from './ConversationAvatar';
+import { CustomListItemButton } from './CustomListItemButton';
+import { useDialogHandler } from './Dialog';
+import LoadingPage from './Loading';
+import { CheckMarkIcon, PersonWithCrossMarkIcon, SaltireIcon } from './SvgIcon';
+
+export const ConversationRequestList = () => {
+  const conversationRequestsQuery = useConversationRequestsQuery();
+  const conversationRequests = conversationRequestsQuery.data;
+
+  return (
+    <List>
+      {conversationRequests?.map((conversationRequest) => (
+        <ConversationRequestListItem
+          key={conversationRequest.conversationId}
+          conversationRequest={conversationRequest}
+        />
+      ))}
+    </List>
+  );
+};
+
+type ConversationRequestListItemProps = {
+  conversationRequest: IConversationRequest;
+};
+
+const ConversationRequestListItem = ({ conversationRequest }: ConversationRequestListItemProps) => {
+  const dialogHandler = useDialogHandler();
+  const infos = conversationRequest.infos;
+
+  const conversationName = useConversationDisplayNameShort(null, infos.title, conversationRequest.membersNames);
+
+  return (
+    <>
+      <HandleConversationRequestDialog {...dialogHandler.props} conversationRequest={conversationRequest} />
+      <CustomListItemButton
+        onClick={dialogHandler.openDialog}
+        icon={<ConversationAvatar displayName={conversationName} src={infos.avatar} />}
+        primaryText={<Typography variant="body1">{conversationName}</Typography>}
+      />
+    </>
+  );
+};
+
+type HandleConversationRequestDialogProps = DialogProps & {
+  conversationRequest: IConversationRequest;
+};
+
+const HandleConversationRequestDialog = ({ conversationRequest, ...props }: HandleConversationRequestDialogProps) => {
+  const { t } = useTranslation();
+  const navigate = useNavigate();
+
+  const {
+    conversationId,
+    infos: { avatar, title },
+  } = conversationRequest;
+
+  const contact = useMemo(() => {
+    return new Contact(conversationRequest.from.uri, conversationRequest.from.registeredName);
+  }, [conversationRequest]);
+
+  const closeDialog = useCallback(
+    () => props.onClose?.({}, 'escapeKeyDown'), // dummy arguments
+    [props]
+  );
+
+  const blockConversationRequestMutation = useBlockConversationRequestMutation();
+  const acceptConversationRequestMutation = useAcceptConversationRequestMutation();
+  const declineConversationRequestMutation = useDeclineConversationRequestMutation();
+
+  const blockConversationRequest = useCallback(() => {
+    blockConversationRequestMutation.mutate(
+      { conversationId },
+      {
+        onSettled: closeDialog,
+      }
+    );
+  }, [blockConversationRequestMutation, conversationId, closeDialog]);
+
+  const acceptConversationRequest = useCallback(() => {
+    acceptConversationRequestMutation.mutate(
+      { conversationId },
+      {
+        onSuccess: () => navigate(`/conversation/${conversationId}`),
+        onSettled: closeDialog,
+      }
+    );
+  }, [acceptConversationRequestMutation, conversationId, closeDialog, navigate]);
+
+  const declineConversationRequest = useCallback(() => {
+    declineConversationRequestMutation.mutate(
+      { conversationId },
+      {
+        onSettled: closeDialog,
+      }
+    );
+  }, [declineConversationRequestMutation, conversationId, closeDialog]);
+
+  return (
+    <Dialog {...props}>
+      <Stack alignItems="center" spacing="40px" position="relative">
+        <Typography variant="caption" visibility={acceptConversationRequestMutation.isLoading ? 'hidden' : 'visible'}>
+          {t('conversation_request_has_sent_request', { contact: contact.getDisplayName() })}
+        </Typography>
+        <ConversationAvatar displayName={title} src={avatar} sx={{ width: '112px', height: '112px' }} />
+        {acceptConversationRequestMutation.isLoading ? (
+          <>
+            <Typography variant="h3" whiteSpace="pre-line" textAlign="center" fontWeight="bold">
+              {t('conversation_request_accepted')}
+            </Typography>
+            <Typography variant="caption" whiteSpace="pre-line" textAlign="center">
+              {t('conversation_request_waiting_for_sync', { contact: contact.getDisplayName() })}
+            </Typography>
+            <LoadingPage />
+          </>
+        ) : (
+          <>
+            <Typography variant="h3" whiteSpace="pre-line" textAlign="center" fontWeight="bold">
+              {t('conversation_request_ask_join')}
+            </Typography>
+            <Stack direction="row" spacing="30px">
+              <ColoredRoundButton
+                aria-label="block conversation"
+                onClick={blockConversationRequest}
+                Icon={PersonWithCrossMarkIcon}
+                paletteColor={(theme) => theme.palette.warning}
+              />
+              <ColoredRoundButton
+                aria-label="decline conversation request"
+                onClick={declineConversationRequest}
+                Icon={SaltireIcon}
+                paletteColor={(theme) => theme.palette.error}
+              />
+              <ColoredRoundButton
+                aria-label="accept conversation request"
+                onClick={acceptConversationRequest}
+                Icon={CheckMarkIcon}
+                paletteColor={(theme) => theme.palette.success}
+              />
+            </Stack>
+          </>
+        )}
+      </Stack>
+    </Dialog>
+  );
+};
diff --git a/client/src/components/SvgIcon.tsx b/client/src/components/SvgIcon.tsx
index 2d9ec16..4e27359 100644
--- a/client/src/components/SvgIcon.tsx
+++ b/client/src/components/SvgIcon.tsx
@@ -240,6 +240,37 @@
   );
 };
 
+export const CheckMarkIcon = (props: SvgIconProps) => {
+  return (
+    <SvgIcon {...props} viewBox="0 0 24 24">
+      <path d="m8.9 19.5-6.5-5.3c-.4-.4-.5-1-.1-1.4.4-.4 1-.5 1.4-.1l5 4.1c1.4-1.4 2.8-2.9 4.2-4.3 2.4-2.6 5-5.2 7.4-7.7.4-.4 1.1-.4 1.5 0 .4.4.4 1.1 0 1.5-2.4 2.4-4.9 5.1-7.4 7.6-1.6 1.7-3.2 3.4-4.8 5l-.7.6z" />
+      <defs>
+        <path
+          id="a"
+          d="m8.9 19.5-6.5-5.3c-.4-.4-.5-1-.1-1.4.4-.4 1-.5 1.4-.1l5 4.1c1.4-1.4 2.8-2.9 4.2-4.3 2.4-2.6 5-5.2 7.4-7.7.4-.4 1.1-.4 1.5 0 .4.4.4 1.1 0 1.5-2.4 2.4-4.9 5.1-7.4 7.6-1.6 1.7-3.2 3.4-4.8 5l-.7.6z"
+        />
+      </defs>
+      {/* <use xlink:href="#a" overflow="visible"/>
+      <clipPath id="b">
+        <use xlink:href="#a" overflow="visible"/>
+      </clipPath>
+      <g clip-path="url(#b)">
+        <path d="M457.6 382.9H-1026v-1027H457.6v1027zm-1481.6-2H455.5v-1023H-1024v1023z"/>
+        <defs>
+          <path id="c" d="M457.6 382.9H-1026v-1027H457.6v1027zm-1481.6-2H455.5v-1023H-1024v1023z"/>
+          </defs>
+        <use xlink:href="#c" overflow="visible"/>
+        <clipPath id="d">
+          <use xlink:href="#c" overflow="visible"/>
+        </clipPath>
+        <g clip-path="url(#d)">
+          <path d="M31.4 29.4H-7.1v-32h38.5v32zm-36.5-2.1h34.4V-.5H-5.1v27.8z"/>
+        </g>
+      </g> */}
+    </SvgIcon>
+  );
+};
+
 export const CrossedEyeIcon = (props: SvgIconProps) => {
   return (
     <SvgIcon {...props} viewBox="0 0 15.931 12.145">
@@ -553,6 +584,15 @@
   );
 };
 
+export const PersonWithCrossMarkIcon = (props: SvgIconProps) => {
+  return (
+    <SvgIcon {...props} viewBox="0 0 24 24">
+      <path d="m14.5 13.1.7-.4c-1-.7-2.1-1.2-3.3-1.4 1.9-.6 3.2-2.3 3.2-4.4.1-2.6-2-4.6-4.5-4.6S6 4.4 6 7c0 2.1 1.4 3.8 3.2 4.4C5.1 12.1 2 16 2 20.7c0 .4.3.7.7.7s.7-.3.7-.7c0-4.5 3.3-8.2 7.4-8.2 1.3 0 2.5.4 3.6 1.1 0-.3.1-.5.1-.5zm-3.9-2.8c-1.9 0-3.4-1.5-3.4-3.3 0-1.9 1.5-3.4 3.4-3.4S14 5.1 14 7s-1.5 3.3-3.4 3.3z" />
+      <path d="M16.9 11.5c-2.8 0-5.1 2.3-5.1 5.1s2.3 5.1 5.1 5.1 5.1-2.3 5.1-5.1-2.3-5.1-5.1-5.1zm3.4 6.9L18 16.3l2.2-1.9c.4.6.7 1.4.7 2.2-.1.7-.3 1.3-.6 1.8zm-1-4.9-2.2 2-2.3-2.1c.6-.4 1.4-.7 2.2-.7.8 0 1.6.3 2.3.8zm-5.4.8 2.3 2-2.5 2.3c-.3-.6-.6-1.3-.6-2 0-.9.3-1.7.8-2.3zm.5 5.2 2.7-2.4 2.5 2.3c-.7.7-1.6 1.1-2.7 1.1-.9 0-1.8-.4-2.5-1z" />
+    </SvgIcon>
+  );
+};
+
 export const PlaceAudioCallIcon = (props: SvgIconProps) => {
   return (
     <SvgIcon {...props} viewBox="0 0 24 24">
diff --git a/client/src/components/Tabs.tsx b/client/src/components/Tabs.tsx
index 9f7fa20..7e7f3b2 100644
--- a/client/src/components/Tabs.tsx
+++ b/client/src/components/Tabs.tsx
@@ -15,7 +15,15 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { TabPanelUnstyled, TabsListUnstyled, TabsUnstyled, TabUnstyled, TabUnstyledProps } from '@mui/base';
+import {
+  TabPanelUnstyled,
+  TabPanelUnstyledProps,
+  TabsListUnstyled,
+  TabsUnstyled,
+  TabsUnstyledProps,
+  TabUnstyled,
+  TabUnstyledProps,
+} from '@mui/base';
 import { Theme } from '@mui/material/styles';
 import { styled } from '@mui/system';
 
@@ -58,6 +66,8 @@
   },
 }));
 
+export type TabPanelProps = TabPanelUnstyledProps;
+
 export const TabPanel = styled(TabPanelUnstyled)(() => ({
   width: '100%',
 }));
@@ -69,4 +79,6 @@
   borderBottom: '1px solid #bfbfbf',
 }));
 
+export type TabsProps = TabsUnstyledProps;
+
 export const Tabs = TabsUnstyled;
diff --git a/client/src/contexts/MessengerProvider.tsx b/client/src/contexts/MessengerProvider.tsx
index fba24dc..2f86ad9 100644
--- a/client/src/contexts/MessengerProvider.tsx
+++ b/client/src/contexts/MessengerProvider.tsx
@@ -15,10 +15,10 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { ConversationMessage, WebSocketMessageType } from 'jami-web-common';
+import { ConversationMessage, IConversationRequest, WebSocketMessageType } from 'jami-web-common';
 import { createContext, ReactNode, useContext, useEffect, useMemo } from 'react';
 
-import { useRefreshConversationsSummaries } from '../services/conversationQueries';
+import { useAddConversationRequestToCache, useRefreshConversationsSummaries } from '../services/conversationQueries';
 import { WebSocketContext } from './WebSocketProvider';
 
 // It is not sure yet we want this context to have no value
@@ -33,6 +33,7 @@
   const webSocket = useContext(WebSocketContext);
 
   const refreshConversationsSummaries = useRefreshConversationsSummaries();
+  const addConversationRequestToCache = useAddConversationRequestToCache();
 
   useEffect(() => {
     if (!webSocket) {
@@ -43,12 +44,18 @@
       refreshConversationsSummaries();
     };
 
+    const conversationRequestListener = (conversationRequest: IConversationRequest) => {
+      addConversationRequestToCache(conversationRequest);
+    };
+
     webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
+    webSocket.bind(WebSocketMessageType.ConversationRequest, conversationRequestListener);
 
     return () => {
       webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
+      webSocket.unbind(WebSocketMessageType.ConversationRequest, conversationRequestListener);
     };
-  }, [refreshConversationsSummaries, webSocket]);
+  }, [addConversationRequestToCache, refreshConversationsSummaries, webSocket]);
 
   const value = useMemo<IMessengerContext>(() => ({}), []);
 
diff --git a/client/src/hooks/useConversationDisplayName.ts b/client/src/hooks/useConversationDisplayName.ts
index 3949c45..f64012e 100644
--- a/client/src/hooks/useConversationDisplayName.ts
+++ b/client/src/hooks/useConversationDisplayName.ts
@@ -55,3 +55,29 @@
     return translateEnumeration<ConversationMember>(members, options);
   }, [account, adminTitle, members, t]);
 };
+
+export const useConversationDisplayNameShort = (
+  account: Account | null,
+  title: string | undefined,
+  membersNames: string[]
+): string => {
+  return useMemo(() => {
+    if (title) {
+      return title;
+    }
+
+    if (membersNames.length === 0) {
+      return account?.getDisplayName() || '';
+    }
+
+    const twoFirstMembers = membersNames.slice(0, 2);
+    const baseName = twoFirstMembers.join(', ');
+    const excess = membersNames.length - twoFirstMembers.length;
+
+    if (excess > 0) {
+      return baseName + ' +' + excess;
+    } else {
+      return baseName;
+    }
+  }, [account, title, membersNames]);
+};
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 08602b4..8fff2ae 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -28,6 +28,10 @@
   "conversation_details_name": "Title",
   "conversation_details_qr_code": "QR code",
   "conversation_message": "Message",
+  "conversation_request_accepted": "You have accepted\nthe conversation request",
+  "conversation_request_ask_join": "Hello,\nWould you like to join the conversation?",
+  "conversation_request_has_sent_request": "{{contact}} has sent you a request for a conversation.",
+  "conversation_request_waiting_for_sync": "Waiting until {{contact}}\nconnects to synchronize the conversation.",
   "conversation_start_audiocall": "Start audio call",
   "conversation_start_videocall": "Start video call",
   "conversation_title_1": "{{member0}}",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index d970bf7..ac4e522 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -28,6 +28,10 @@
   "conversation_details_name": "Titre",
   "conversation_details_qr_code": "Code QR",
   "conversation_message": "Envoyer un message",
+  "conversation_request_accepted": "Vous avez accepté\nla demande de conversation",
+  "conversation_request_ask_join": "Bonjour,\nSouhaitez-vous rejoindre la conversation ?",
+  "conversation_request_has_sent_request": "{{contact}} vous a envoyé une demande de conversation.",
+  "conversation_request_waiting_for_sync": "En attente de la synchronisation de la conversation\npar {{contact}}.",
   "conversation_start_audiocall": "Démarrer appel audio",
   "conversation_start_videocall": "Démarrer appel vidéo",
   "conversation_title_1": "{{member0}}",
diff --git a/client/src/models/contact.ts b/client/src/models/contact.ts
index e696db0..d516169 100644
--- a/client/src/models/contact.ts
+++ b/client/src/models/contact.ts
@@ -32,10 +32,10 @@
   }
 
   getDisplayName() {
-    return this.getDisplayNameNoFallback() ?? this.uri;
+    return this.getDisplayNameNoFallback() || this.uri;
   }
 
   getDisplayNameNoFallback() {
-    return this.displayName ?? this.registeredName;
+    return this.displayName || this.registeredName;
   }
 }
diff --git a/client/src/services/contactQueries.ts b/client/src/services/contactQueries.ts
index 2b952de..5440135 100644
--- a/client/src/services/contactQueries.ts
+++ b/client/src/services/contactQueries.ts
@@ -34,6 +34,19 @@
   });
 };
 
+export const useContactQuery = (contactId?: string) => {
+  const { axiosInstance } = useAuthContext();
+
+  return useQuery({
+    queryKey: ['contacts', contactId],
+    queryFn: async () => {
+      const { data } = await axiosInstance.get<ContactDetails>(`/contacts/${contactId}`);
+      return data;
+    },
+    enabled: !!contactId,
+  });
+};
+
 export const useAddContactMutation = () => {
   const { axiosInstance } = useAuthContext();
 
diff --git a/client/src/services/conversationQueries.ts b/client/src/services/conversationQueries.ts
index 538d0e0..e33a9cc 100644
--- a/client/src/services/conversationQueries.ts
+++ b/client/src/services/conversationQueries.ts
@@ -16,11 +16,19 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
-import { ConversationInfos, IConversationMember, IConversationSummary, Message } from 'jami-web-common';
+import { AxiosResponse } from 'axios';
+import {
+  ConversationInfos,
+  IConversationMember,
+  IConversationRequest,
+  IConversationSummary,
+  Message,
+} from 'jami-web-common';
 import { useCallback } from 'react';
 
 import { useAuthContext } from '../contexts/AuthProvider';
 import { ConversationMember } from '../models/conversation-member';
+import { useAddToCache, useRemoveFromCache } from '../utils/reactquery';
 
 export const useConversationInfosQuery = (conversationId?: string) => {
   const { axiosInstance } = useAuthContext();
@@ -44,6 +52,31 @@
   });
 };
 
+const checkConversationSummariesAreEqual = (
+  conversationSummary1: IConversationSummary,
+  conversationSummary2: IConversationSummary
+) => conversationSummary1.id === conversationSummary2.id;
+const checkIsConversationSummaryFn = (conversationSummary: IConversationSummary, conversationId: string) =>
+  conversationSummary.id === conversationId;
+
+export const useAddConversationSummaryToCache = () =>
+  useAddToCache(['conversations', 'summaries'], checkConversationSummariesAreEqual);
+export const useRemoveConversationSummaryFromCache = () =>
+  useRemoveFromCache(['conversations', 'summaries'], checkIsConversationSummaryFn);
+
+export const useRemoveConversationMutation = () => {
+  const { axiosInstance } = useAuthContext();
+  const removeConversationSummaryFromCache = useRemoveConversationSummaryFromCache();
+  return useMutation(
+    ({ conversationId }: { conversationId: string }) => axiosInstance.delete(`/conversations/${conversationId}`),
+    {
+      onSuccess: (_data, { conversationId }) => {
+        removeConversationSummaryFromCache(conversationId);
+      },
+    }
+  );
+};
+
 export const useRefreshConversationsSummaries = () => {
   const queryClient = useQueryClient();
   return useCallback(() => {
@@ -89,3 +122,74 @@
     }
   );
 };
+
+const CheckConversationRequestsAreEqual = (
+  conversationRequest1: IConversationRequest,
+  conversationRequest2: IConversationRequest
+) => conversationRequest1.conversationId === conversationRequest2.conversationId;
+const checkIsConversationRequestFn = (conversationRequest: IConversationRequest, conversationId: string) =>
+  conversationRequest.conversationId === conversationId;
+
+export const useAddConversationRequestToCache = () =>
+  useAddToCache(['conversationsRequests'], CheckConversationRequestsAreEqual);
+export const useRemoveConversationRequestFromCache = () =>
+  useRemoveFromCache(['conversationsRequests'], checkIsConversationRequestFn);
+
+export const useConversationRequestsQuery = () => {
+  const { axiosInstance } = useAuthContext();
+  return useQuery({
+    queryKey: ['conversationRequests'],
+    queryFn: async () => {
+      const { data } = await axiosInstance.get<IConversationRequest[]>('/conversation-requests/');
+      return data;
+    },
+  });
+};
+
+export const useAcceptConversationRequestMutation = () => {
+  const { axiosInstance } = useAuthContext();
+  const addConversationSummaryToCache = useAddConversationSummaryToCache();
+  const removeConversationRequestFromCache = useRemoveConversationRequestFromCache();
+  return useMutation(
+    async (variables: { conversationId: string }) => {
+      const { data } = await axiosInstance.post<undefined, AxiosResponse<IConversationSummary>>(
+        `/conversation-requests/${variables.conversationId}`
+      );
+      return data;
+    },
+    {
+      onSuccess: (data, { conversationId }) => {
+        addConversationSummaryToCache(data);
+        removeConversationRequestFromCache(conversationId);
+      },
+    }
+  );
+};
+
+export const useBlockConversationRequestMutation = () => {
+  const { axiosInstance } = useAuthContext();
+  const removeConversationRequestFromCache = useRemoveConversationRequestFromCache();
+  return useMutation(
+    ({ conversationId }: { conversationId: string }) =>
+      axiosInstance.post(`/conversation-requests/${conversationId}/block`),
+    {
+      onSuccess: (_data, { conversationId }) => {
+        removeConversationRequestFromCache(conversationId);
+      },
+    }
+  );
+};
+
+export const useDeclineConversationRequestMutation = () => {
+  const { axiosInstance } = useAuthContext();
+  const removeConversationRequestFromCache = useRemoveConversationRequestFromCache();
+  return useMutation(
+    ({ conversationId }: { conversationId: string }) =>
+      axiosInstance.delete(`/conversation-requests/${conversationId}`),
+    {
+      onSuccess: (_data, { conversationId }) => {
+        removeConversationRequestFromCache(conversationId);
+      },
+    }
+  );
+};
diff --git a/client/src/utils/reactquery.ts b/client/src/utils/reactquery.ts
new file mode 100644
index 0000000..6ae5f71
--- /dev/null
+++ b/client/src/utils/reactquery.ts
@@ -0,0 +1,67 @@
+/*
+ * 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 { QueryKey, useQueryClient } from '@tanstack/react-query';
+import { useCallback } from 'react';
+
+// Test whether two elements are equals or not
+// Return 'true' if they are equal, 'false' otherwise
+export type CheckElementsAreEqual<ElementType> = (element1: ElementType, element2: ElementType) => boolean;
+
+type AddToCacheFn<ElementType> = (element: ElementType) => void;
+
+export const useAddToCache = <ElementType>(
+  queryKey: QueryKey,
+  CheckElementsAreEqual: CheckElementsAreEqual<ElementType>
+): AddToCacheFn<ElementType> => {
+  const queryClient = useQueryClient();
+  return useCallback(
+    (newElement: ElementType) => {
+      // Make sure the element is not already in the cache
+      // This is expected to happen all the time on strict mode
+      const data = queryClient.getQueryData<ElementType[]>(queryKey);
+      if (data?.find((element) => CheckElementsAreEqual(element, newElement))) {
+        return;
+      }
+
+      // Add the element
+      queryClient.setQueryData<ElementType[]>(queryKey, (elements) => [...(elements || []), newElement]);
+    },
+    [CheckElementsAreEqual, queryClient, queryKey]
+  );
+};
+
+// Check whether the passed element is the one we are looking for or not
+type CheckIsElementFn<ElementType, IdentifierType> = (element: ElementType, identifier: IdentifierType) => boolean;
+
+type RemoveFromCacheFn<IdentifierType> = (element: IdentifierType) => void;
+
+export const useRemoveFromCache = <ElementType, IdentifierType>(
+  queryKey: QueryKey,
+  checkIsElementFn: CheckIsElementFn<ElementType, IdentifierType>
+): RemoveFromCacheFn<IdentifierType> => {
+  const queryClient = useQueryClient();
+  return useCallback(
+    (identifier: IdentifierType) => {
+      queryClient.setQueryData<ElementType[]>(queryKey, (elements) =>
+        elements?.filter((element) => !checkIsElementFn(element, identifier))
+      );
+    },
+    [checkIsElementFn, queryClient, queryKey]
+  );
+};