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/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>
+  );
+};