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×';
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;