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;