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