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;
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]
+ );
+};
diff --git a/common/src/enums/websocket-message-type.ts b/common/src/enums/websocket-message-type.ts
index 37036b9..512c1dc 100644
--- a/common/src/enums/websocket-message-type.ts
+++ b/common/src/enums/websocket-message-type.ts
@@ -17,6 +17,7 @@
*/
export enum WebSocketMessageType {
ConversationMessage = 'conversation-message',
+ ConversationRequest = 'conversation-request',
ConversationView = 'conversation-view',
OnComposingStatusChanged = 'on-composing-status-changed', // Sent by server to indicate who is composing a message or not
SetIsComposing = 'set-is-composing', // Sent by user to indicate whether they are composing a message or not
diff --git a/common/src/interfaces/conversation.ts b/common/src/interfaces/conversation.ts
index f49f72e..1304adc 100644
--- a/common/src/interfaces/conversation.ts
+++ b/common/src/interfaces/conversation.ts
@@ -69,3 +69,11 @@
membersNames: string[];
lastMessage: Message;
}
+
+export interface IConversationRequest {
+ conversationId: string;
+ infos: ConversationInfos;
+ from: IContact;
+ received: string;
+ membersNames: string[];
+}
diff --git a/common/src/interfaces/websocket-message.ts b/common/src/interfaces/websocket-message.ts
index af3078c..d633059 100644
--- a/common/src/interfaces/websocket-message.ts
+++ b/common/src/interfaces/websocket-message.ts
@@ -16,6 +16,7 @@
* <https://www.gnu.org/licenses/>.
*/
import { WebSocketMessageType } from '../enums/websocket-message-type.js';
+import { IConversationRequest } from './conversation.js';
import {
CallAction,
CallBegin,
@@ -28,6 +29,7 @@
export interface WebSocketMessageTable {
[WebSocketMessageType.ConversationMessage]: ConversationMessage;
+ [WebSocketMessageType.ConversationRequest]: IConversationRequest;
[WebSocketMessageType.ConversationView]: ConversationView;
[WebSocketMessageType.OnComposingStatusChanged]: ComposingStatus;
[WebSocketMessageType.SetIsComposing]: ComposingStatus;
diff --git a/server/src/app.ts b/server/src/app.ts
index 0e1b055..39dbffa 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -27,6 +27,7 @@
import { authRouter } from './routers/auth-router.js';
import { callRouter } from './routers/call-router.js';
import { contactsRouter } from './routers/contacts-router.js';
+import { conversationRequestRouter } from './routers/conversation-request-router.js';
import { conversationRouter } from './routers/conversation-router.js';
import { defaultModeratorsRouter } from './routers/default-moderators-router.js';
import { linkPreviewRouter } from './routers/link-preview-router.js';
@@ -55,6 +56,7 @@
this.app.use('/contacts', contactsRouter);
this.app.use('/default-moderators', defaultModeratorsRouter);
this.app.use('/conversations', conversationRouter);
+ this.app.use('/conversation-requests', conversationRequestRouter);
this.app.use('/calls', callRouter);
this.app.use('/link-preview', linkPreviewRouter);
this.app.use('/ns', nameserverRouter);
diff --git a/server/src/jamid/conversation-request-metadata.ts b/server/src/jamid/conversation-request-metadata.ts
index d6c187b..28f6208 100644
--- a/server/src/jamid/conversation-request-metadata.ts
+++ b/server/src/jamid/conversation-request-metadata.ts
@@ -19,4 +19,8 @@
id: string;
from: string;
received: string;
+ avatar: string;
+ description: string;
+ title: string;
+ mode: string;
}
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index 07fa2dd..9e078bd 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -118,9 +118,12 @@
getConversations(accountId: string): StringVect;
conversationInfos(accountId: string, conversationId: string): StringMap;
getConversationMembers(accountId: string, conversationId: string): VectMap;
- acceptConversationRequest(accountId: string, conversationId: string): void;
removeConversation(accountId: string, conversationId: string): void;
+ getConversationRequests(accountId: string): VectMap;
+ acceptConversationRequest(accountId: string, conversationId: string): void;
+ declineConversationRequest(accountId: string, conversationId: string): void;
+
sendMessage(accountId: string, conversationId: string, message: string, replyTo: string, flag: number): void;
loadConversationMessages(accountId: string, conversationId: string, fromMessage: string, n: number): number;
setIsComposing(accountId: string, conversationId: string, isWriting: boolean): void;
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index ab4d8a8..15ff23c 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -72,7 +72,7 @@
export class Jamid {
private jamiSwig: JamiSwig;
private usernamesToAccountIds = new Map<string, string>();
- private readonly events;
+ readonly events;
constructor(private webSocketServer: WebSocketServer) {
this.jamiSwig = require('../../jamid.node') as JamiSwig;
@@ -276,6 +276,7 @@
async lookupAddress(address: string, accountId?: string): Promise<LookupResult> {
const hasRingNs = this.jamiSwig.lookupAddress(accountId || '', '', address);
+
if (!hasRingNs) {
throw new Error('Jami does not have a nameserver');
}
@@ -375,6 +376,26 @@
this.jamiSwig.removeConversation(accountId, conversationId);
}
+ getConversationRequests(accountId: string): ConversationRequestMetadata[] {
+ return vectMapToRecordArray(
+ this.jamiSwig.getConversationRequests(accountId)
+ ) as unknown as ConversationRequestMetadata[];
+ }
+
+ acceptConversationRequest(accountId: string, conversationId: string): Promise<ConversationReady> {
+ this.jamiSwig.acceptConversationRequest(accountId, conversationId);
+ return firstValueFrom(
+ this.events.onConversationReady.pipe(
+ filter((value) => value.accountId === accountId),
+ filter((value) => value.conversationId === conversationId)
+ )
+ );
+ }
+
+ declineConversationRequest(accountId: string, conversationId: string): void {
+ this.jamiSwig.declineConversationRequest(accountId, conversationId);
+ }
+
sendConversationMessage(
accountId: string,
conversationId: string,
@@ -477,13 +498,7 @@
this.events.onConversationRequestReceived.subscribe((signal) => {
log.debug('Received ConversationRequestReceived:', JSON.stringify(signal));
-
- // TODO: Prompt user to accept conversation request on client
- // Currently, we auto-accept all incoming conversation requests. In future, we
- // need to ask the user if they accept the conversation request or not. Part of
- // it can be done by sending a WebSocket event.
- // See other implementations e.g. block contact / decline request / accept request.
- this.jamiSwig.acceptConversationRequest(signal.accountId, signal.conversationId);
+ //this.webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationRequest, data);
});
this.events.onConversationReady.subscribe((signal) => {
diff --git a/server/src/routers/conversation-request-router.ts b/server/src/routers/conversation-request-router.ts
new file mode 100644
index 0000000..f0ef566
--- /dev/null
+++ b/server/src/routers/conversation-request-router.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { Router } from 'express';
+import asyncHandler from 'express-async-handler';
+import { HttpStatusCode, IConversationRequest } from 'jami-web-common';
+import { Container } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+import { authenticateToken } from '../middleware/auth.js';
+import { ConversationService } from '../services/ConversationService.js';
+
+const jamid = Container.get(Jamid);
+const conversationService = Container.get(ConversationService);
+
+export const conversationRequestRouter = Router();
+
+conversationRequestRouter.use(authenticateToken);
+
+conversationRequestRouter.get(
+ '/',
+ asyncHandler(async (_req, res) => {
+ const accountId = res.locals.accountId;
+ const jamidRequests = jamid.getConversationRequests(accountId);
+ Promise.all(
+ jamidRequests.map((jamidRequest) => conversationService.createConversationRequest(accountId, jamidRequest))
+ )
+ .then((apiRequests: IConversationRequest[]) => res.send(apiRequests))
+ .catch((err) => res.status(HttpStatusCode.InternalServerError).send(err.message));
+ })
+);
+
+conversationRequestRouter.post(
+ '/:conversationId',
+ asyncHandler(async (req, res) => {
+ const accountId = res.locals.accountId;
+ const conversationId = req.params.conversationId;
+ await jamid.acceptConversationRequest(accountId, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
+ if (conversationSummary === undefined) {
+ res.status(HttpStatusCode.NotFound).send('No such conversation found');
+ return;
+ }
+ res.send(conversationSummary);
+ })
+);
+
+conversationRequestRouter.delete(
+ '/:conversationId',
+ asyncHandler(async (req, res) => {
+ jamid.declineConversationRequest(res.locals.accountId, req.params.conversationId);
+ res.sendStatus(HttpStatusCode.NoContent);
+ })
+);
+
+conversationRequestRouter.post('/:conversationId/block', (req, res) => {
+ const accountId = res.locals.accountId;
+ const conversationId = req.params.conversationId;
+ const conversationRequests = jamid.getConversationRequests(accountId);
+ const conversationRequest = conversationRequests.filter((request) => request.id === conversationId)[0];
+ if (!conversationRequest) {
+ res.status(HttpStatusCode.NotFound).send('No such conversation request found');
+ }
+ jamid.blockContact(accountId, conversationRequest.from);
+ res.sendStatus(HttpStatusCode.NoContent);
+});
diff --git a/server/src/routers/conversation-router.ts b/server/src/routers/conversation-router.ts
index aa65723..c38e46d 100644
--- a/server/src/routers/conversation-router.ts
+++ b/server/src/routers/conversation-router.ts
@@ -22,8 +22,6 @@
ContactDetails,
HttpStatusCode,
IConversationMember,
- IConversationSummary,
- Message,
NewConversationRequestBody,
NewMessageRequestBody,
} from 'jami-web-common';
@@ -31,50 +29,10 @@
import { Jamid } from '../jamid/jamid.js';
import { authenticateToken } from '../middleware/auth.js';
+import { ConversationService } from '../services/ConversationService.js';
const jamid = Container.get(Jamid);
-
-async function createConversationSummary(
- accountId: string,
- accountUri: string,
- conversationId: string
-): Promise<IConversationSummary | undefined> {
- const infos = jamid.getConversationInfos(accountId, conversationId);
- if (Object.keys(infos).length === 0) {
- return undefined;
- }
-
- const members = jamid.getConversationMembers(accountId, conversationId);
-
- const membersNames = [];
- for (const member of members) {
- // Exclude current user from returned conversation members
- if (member.uri === accountUri) {
- continue;
- }
-
- // Add usernames for conversation members
- const { username } = await jamid.lookupAddress(member.uri, accountId);
- membersNames.push(username || member.uri);
- }
-
- let lastMessage: Message | undefined;
- // Skip "merge" type since they are of no interest for the user
- // Should we add some protection to prevent infinite loop?
- while (!lastMessage || lastMessage.type === 'merge') {
- lastMessage = (
- await jamid.getConversationMessages(accountId, conversationId, lastMessage?.linearizedParent || '', 1)
- )[0];
- }
-
- return {
- id: conversationId,
- avatar: infos.avatar,
- title: infos.title,
- membersNames,
- lastMessage,
- };
-}
+const conversationService = Container.get(ConversationService);
export const conversationRouter = Router();
@@ -85,14 +43,11 @@
asyncHandler(async (_req, res) => {
const accountId = res.locals.accountId;
- // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
- const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
-
const conversationIds = jamid.getConversationIds(accountId);
const conversationsSummaries = [];
for (const conversationId of conversationIds) {
- const conversationSummary = await createConversationSummary(accountId, accountUri, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
conversationsSummaries.push(conversationSummary);
}
@@ -132,10 +87,7 @@
const accountId = res.locals.accountId;
const conversationId = req.params.conversationId;
- // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
- const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
-
- const conversationSummary = await createConversationSummary(accountId, accountUri, conversationId);
+ const conversationSummary = await conversationService.createConversationSummary(accountId, conversationId);
if (conversationSummary === undefined) {
res.status(HttpStatusCode.NotFound).send('No such conversation found');
return;
@@ -204,13 +156,11 @@
asyncHandler(async (req, res) => {
const accountId = res.locals.accountId;
const conversationId = req.params.conversationId;
-
const infos = jamid.getConversationInfos(accountId, conversationId);
if (Object.keys(infos).length === 0) {
res.status(HttpStatusCode.NotFound).send('No such conversation found');
return;
}
-
const messages = await jamid.getConversationMessages(accountId, conversationId);
res.send(messages);
})
diff --git a/server/src/services/ContactService.ts b/server/src/services/ContactService.ts
new file mode 100644
index 0000000..1ec626f
--- /dev/null
+++ b/server/src/services/ContactService.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { IContact } from 'jami-web-common';
+import { Container, Service } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+
+const jamid = Container.get(Jamid);
+
+@Service()
+export class ContactService {
+ async getContactFromUri(accountId: string, contactUri: string): Promise<IContact> {
+ const { username } = await jamid.lookupAddress(contactUri, accountId);
+
+ return {
+ uri: contactUri,
+ registeredName: username,
+ };
+ }
+}
diff --git a/server/src/services/ConversationService.ts b/server/src/services/ConversationService.ts
new file mode 100644
index 0000000..5265c16
--- /dev/null
+++ b/server/src/services/ConversationService.ts
@@ -0,0 +1,119 @@
+/*
+ * 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 { IConversationRequest, IConversationSummary, Message, WebSocketMessageType } from 'jami-web-common';
+import { Container, Service } from 'typedi';
+
+import { ConversationRequestMetadata } from '../jamid/conversation-request-metadata.js';
+import { Jamid } from '../jamid/jamid.js';
+import { WebSocketServer } from '../websocket/websocket-server.js';
+import { ContactService } from './ContactService.js';
+
+const jamid = Container.get(Jamid);
+const webSocketServer = Container.get(WebSocketServer);
+const contactService = Container.get(ContactService);
+
+@Service()
+export class ConversationService {
+ constructor() {
+ jamid.events.onConversationRequestReceived.subscribe(async (signal) => {
+ const conversationRequest = await this.createConversationRequest(signal.accountId, signal.metadata);
+ webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationRequest, conversationRequest);
+ });
+ }
+
+ async createConversationRequest(
+ accountId: string,
+ jamidRequest: ConversationRequestMetadata
+ ): Promise<IConversationRequest> {
+ const contact = await contactService.getContactFromUri(accountId, jamidRequest.from);
+ const membersNames = await this.getMembersNames(accountId, jamidRequest.id);
+
+ // Currently, this does not actually works.
+ // It seems the members can't be accessed yet. Same on jami-android
+ if (membersNames.length === 0) {
+ membersNames.push(contact.registeredName || contact.uri);
+ }
+
+ return {
+ conversationId: jamidRequest.id,
+ received: jamidRequest.received,
+ from: contact,
+ infos: {
+ // Build a dataUrl from the avatar
+ // TODO: Host the image and use a "normal" url instead
+ avatar: `data:image/jpeg;base64,${jamidRequest.avatar}`,
+ description: jamidRequest.description,
+ mode: jamidRequest.mode,
+ title: jamidRequest.title,
+ },
+ membersNames,
+ };
+ }
+
+ async createConversationSummary(
+ accountId: string,
+ conversationId: string
+ ): Promise<IConversationSummary | undefined> {
+ const infos = jamid.getConversationInfos(accountId, conversationId);
+ if (Object.keys(infos).length === 0) {
+ return undefined;
+ }
+ // Build a dataUrl from the avatar
+ // TODO: Host the image and use a "normal" url instead
+ infos.avatar = `data:image/jpeg;base64,${infos.avatar}`;
+
+ const membersNames = await this.getMembersNames(accountId, conversationId);
+ let lastMessage: Message | undefined;
+ // Skip "merge" type since they are of no interest for the user
+ // Should we add some protection to prevent infinite loop?
+ while (!lastMessage || lastMessage.type === 'merge') {
+ lastMessage = (
+ await jamid.getConversationMessages(accountId, conversationId, lastMessage?.linearizedParent || '', 1)
+ )[0];
+ }
+
+ return {
+ id: conversationId,
+ avatar: infos.avatar,
+ title: infos.title,
+ membersNames,
+ lastMessage,
+ };
+ }
+
+ async getMembersNames(accountId: string, conversationId: string): Promise<string[]> {
+ // Retrieve the URI of the current account (Account.username actually stores the URI rather than the username)
+ const accountUri = jamid.getAccountDetails(accountId)['Account.username'];
+
+ const members = jamid.getConversationMembers(accountId, conversationId);
+ const membersNames: string[] = [];
+ for (const member of members) {
+ // Exclude current user from returned conversation members
+ if (member.uri === accountUri) {
+ continue;
+ }
+
+ // Add usernames for conversation members
+ const { username } = await jamid.lookupAddress(member.uri, accountId);
+ membersNames.push(username || member.uri);
+ }
+
+ return membersNames;
+ }
+}