add names to conversation view and set header styles
Change-Id: Ic34b2cea754a5a82224a9fbf158b0126c7e44a5e
diff --git a/client/src/components/Button.tsx b/client/src/components/Button.tsx
index 0d401a2..2325ecb 100644
--- a/client/src/components/Button.tsx
+++ b/client/src/components/Button.tsx
@@ -19,12 +19,13 @@
import { Box, ClickAwayListener, IconButton, IconButtonProps, Popper, SvgIconProps } from '@mui/material';
import { styled } from '@mui/material/styles';
import EmojiPicker, { IEmojiData } from 'emoji-picker-react';
-import React, { ComponentType, MouseEvent, useCallback, useState } from 'react';
+import { ComponentType, MouseEvent, useCallback, useState } from 'react';
import {
Arrow2Icon,
Arrow3Icon,
ArrowIcon,
+ AudioCallIcon,
CallEndIcon,
CameraIcon,
CameraInBubbleIcon,
@@ -39,13 +40,16 @@
FullscreenIcon,
GroupAddIcon,
InfoIcon,
+ ListIcon,
MicroIcon,
MicroInBubbleIcon,
PaperClipIcon,
PenIcon,
+ PeopleWithPlusSignIcon,
RecordingIcon,
SaltireIcon,
ScreenShareIcon,
+ VideoCallIcon,
VideoCameraIcon,
VolumeIcon,
} from './SvgIcon';
@@ -284,6 +288,10 @@
},
}));
+export const AddParticipantButton = (props: IconButtonProps) => {
+ return <SquareButton {...props} aria-label="add participant" Icon={PeopleWithPlusSignIcon} />;
+};
+
export const RecordVideoMessageButton = (props: IconButtonProps) => {
return <SquareButton {...props} aria-label="record video message" Icon={CameraInBubbleIcon} />;
};
@@ -292,6 +300,18 @@
return <SquareButton {...props} aria-label="record voice message" Icon={MicroInBubbleIcon} />;
};
+export const ShowOptionsMenuButton = (props: IconButtonProps) => {
+ return <SquareButton {...props} aria-label="show options menu" Icon={ListIcon} />;
+};
+
+export const StartVideoCallButton = (props: IconButtonProps) => {
+ return <SquareButton {...props} aria-label="start audio call" Icon={AudioCallIcon} />;
+};
+
+export const StartAudioCallButton = (props: IconButtonProps) => {
+ return <SquareButton {...props} aria-label="start video call" Icon={VideoCallIcon} />;
+};
+
export const UploadFileButton = (props: IconButtonProps) => {
return <SquareButton {...props} aria-label="upload file" Icon={PaperClipIcon} />;
};
@@ -356,7 +376,12 @@
return (
<ClickAwayListener onClickAway={handleClose}>
<Box>
- <SquareButton aria-describedby={id} aria-label="select emoji" Icon={EmojiIcon} onClick={(e) => {}} />
+ <SquareButton
+ aria-describedby={id}
+ aria-label="select emoji"
+ Icon={EmojiIcon}
+ onClick={handleOpenEmojiPicker}
+ />
<Popper id={id} open={open} anchorEl={anchorEl}>
<EmojiPicker onEmojiClick={onEmojiClick} disableAutoFocus={true} disableSkinTonePicker={true} native />
</Popper>
diff --git a/client/src/components/ConversationView.tsx b/client/src/components/ConversationView.tsx
index 0a8e22d..a6c0d35 100644
--- a/client/src/components/ConversationView.tsx
+++ b/client/src/components/ConversationView.tsx
@@ -15,13 +15,16 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Box, Stack, Typography } from '@mui/material';
-import { Conversation, Message } from 'jami-web-common';
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { Divider, Stack, Typography } from '@mui/material';
+import { Account, Conversation, ConversationMember, Message } from 'jami-web-common';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
import { SocketContext } from '../contexts/Socket';
+import { useAccountQuery } from '../services/Account';
import { useConversationQuery, useMessagesQuery, useSendMessageMutation } from '../services/Conversation';
-import ConversationAvatar from './ConversationAvatar';
+import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
+import { AddParticipantButton, ShowOptionsMenuButton, StartAudioCallButton, StartVideoCallButton } from './Button';
import LoadingPage from './Loading';
import MessageList from './MessageList';
import SendMessageForm from './SendMessageForm';
@@ -30,18 +33,26 @@
accountId: string;
conversationId: string;
};
-const ConversationView = ({ accountId, conversationId, ...props }: ConversationViewProps) => {
+const ConversationView = ({ accountId, conversationId }: ConversationViewProps) => {
const socket = useContext(SocketContext);
+ const [account, setAccount] = useState<Account | undefined>();
const [conversation, setConversation] = useState<Conversation | undefined>();
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(false);
+ const accountQuery = useAccountQuery(accountId);
const conversationQuery = useConversationQuery(accountId, conversationId);
const messagesQuery = useMessagesQuery(accountId, conversationId);
const sendMessageMutation = useSendMessageMutation(accountId, conversationId);
useEffect(() => {
+ if (accountQuery.isSuccess) {
+ setAccount(Account.from(accountQuery.data));
+ }
+ }, [accountQuery.isSuccess, accountQuery.data]);
+
+ useEffect(() => {
if (conversationQuery.isSuccess) {
const conversation = Conversation.from(accountId, conversationQuery.data);
setConversation(conversation);
@@ -56,12 +67,12 @@
}, [messagesQuery.isSuccess, messagesQuery.data]);
useEffect(() => {
- setIsLoading(conversationQuery.isLoading || messagesQuery.isLoading);
- }, [conversationQuery.isLoading, messagesQuery.isLoading]);
+ setIsLoading(accountQuery.isLoading || conversationQuery.isLoading || messagesQuery.isLoading);
+ }, [accountQuery.isLoading, conversationQuery.isLoading, messagesQuery.isLoading]);
useEffect(() => {
- setError(conversationQuery.isError || messagesQuery.isError);
- }, [conversationQuery.isError, messagesQuery.isError]);
+ setError(accountQuery.isLoading || conversationQuery.isError || messagesQuery.isError);
+ }, [accountQuery.isLoading, conversationQuery.isError, messagesQuery.isError]);
const sendMessage = useCallback((message: string) => sendMessageMutation.mutate(message), [sendMessageMutation]);
@@ -83,35 +94,94 @@
if (isLoading) {
return <LoadingPage />;
- } else if (error) {
+ } else if (error || !account || !conversation) {
return <div>Error loading {conversationId}</div>;
}
return (
- <Stack flexGrow={1} height="100%">
- <Stack direction="row" flexGrow={0}>
- <Box style={{ margin: 16, flexShrink: 0 }}>
- <ConversationAvatar displayName={conversation?.getDisplayNameNoFallback()} />
- </Box>
- <Box style={{ flex: '1 1 auto', overflow: 'hidden' }}>
- <Typography className="title" variant="h6">
- {conversation?.getDisplayName()}
- </Typography>
- <Typography className="subtitle" variant="subtitle1">
- {conversationId}
- </Typography>
- </Box>
+ <Stack height="100%">
+ <Stack padding="16px">
+ <ConversationHeader
+ account={account}
+ members={conversation.getMembers()}
+ adminTitle={conversation.infos.title as string}
+ />
</Stack>
- <Stack flexGrow={1} overflow="auto" direction="column-reverse">
- <MessageList messages={messages} />
+ <Divider
+ sx={{
+ borderTop: '1px solid #E5E5E5',
+ }}
+ />
+ <Stack flex={1} overflow="auto" direction="column-reverse" padding="0px 16px">
+ <MessageList account={account} members={conversation.getMembers()} messages={messages} />
</Stack>
- <Stack flexGrow={0}>
- <SendMessageForm onSend={sendMessage} />
+ <Divider
+ sx={{
+ margin: '30px 16px 0px 16px',
+ borderTop: '1px solid #E5E5E5',
+ }}
+ />
+ <Stack padding="16px">
+ <SendMessageForm account={account} members={conversation.getMembers()} onSend={sendMessage} />
</Stack>
</Stack>
);
};
+type ConversationHeaderProps = {
+ account: Account;
+ members: ConversationMember[];
+ adminTitle: string | undefined;
+};
+
+const ConversationHeader = ({ account, members, adminTitle }: ConversationHeaderProps) => {
+ const { t } = useTranslation();
+
+ const title = useMemo(() => {
+ if (adminTitle) {
+ return adminTitle;
+ }
+
+ const options: TranslateEnumerationOptions<ConversationMember> = {
+ elementPartialKey: 'member',
+ getElementValue: (member) => getMemberName(member),
+ translaters: [
+ () =>
+ // The user is chatting with themself
+ t('conversation_title_one', { member0: account?.getDisplayName() }),
+ (interpolations) => t('conversation_title_one', interpolations),
+ (interpolations) => t('conversation_title_two', interpolations),
+ (interpolations) => t('conversation_title_three', interpolations),
+ (interpolations) => t('conversation_title_four', interpolations),
+ (interpolations) => t('conversation_title_more', interpolations),
+ ],
+ };
+
+ return translateEnumeration<ConversationMember>(members, options);
+ }, [account, members, adminTitle, t]);
+
+ return (
+ <Stack direction="row">
+ <Stack flex={1} justifyContent="center" whiteSpace="nowrap" overflow="hidden">
+ <Typography variant="h3" textOverflow="ellipsis">
+ {title}
+ </Typography>
+ </Stack>
+ <Stack direction="row" spacing="20px">
+ <StartAudioCallButton />
+ <StartVideoCallButton />
+ <AddParticipantButton />
+ <ShowOptionsMenuButton />
+ </Stack>
+ </Stack>
+ );
+};
+
+const getMemberName = (member: ConversationMember) => {
+ const contact = member.contact;
+ return contact.getDisplayName();
+};
+
const addMessage = (sortedMessages: Message[], message: Message) => {
if (sortedMessages.length === 0) {
return [message];
diff --git a/client/src/components/Message.jsx b/client/src/components/Message.jsx
index d5980c6..7e14d10 100644
--- a/client/src/components/Message.jsx
+++ b/client/src/components/Message.jsx
@@ -20,7 +20,7 @@
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
-import React, { useCallback, useMemo, useState } from 'react';
+import { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { EmojiButton, MoreButton, ReplyMessageButton } from './Button.tsx';
@@ -143,11 +143,20 @@
};
export const MessageBubblesGroup = (props) => {
- const isUser = false; // should access user from the store
+ const isUser = props.messages[0]?.author === props.account.getUri();
const position = isUser ? 'end' : 'start';
const bubbleColor = isUser ? '#005699' : '#E5E5E5';
const textColor = isUser ? 'white' : 'black';
+ let authorName;
+ if (isUser) {
+ authorName = props.account.getDisplayName();
+ } else {
+ const member = props.members.find((member) => props.messages[0]?.author === member.contact.getUri());
+ const contact = member.contact;
+ authorName = contact.getDisplayName();
+ }
+
return (
<Stack // Row for a group of message bubbles with the user's infos
direction="row"
@@ -156,13 +165,13 @@
spacing="10px"
>
{!isUser && (
- <ConversationAvatar displayName="TempDisplayName" sx={{ width: '22px', height: '22px', fontSize: '15px' }} />
+ <ConversationAvatar displayName={authorName} sx={{ width: '22px', height: '22px', fontSize: '15px' }} />
)}
<Stack // Container to align the bubbles to the same side of a row
width="66.66%"
alignItems={position}
>
- <ParticipantName name={props.messages[0]?.author} position={position} />
+ <ParticipantName name={authorName} position={position} />
<Stack // Container for a group of message bubbles
spacing="6px"
alignItems={position}
diff --git a/client/src/components/MessageList.jsx b/client/src/components/MessageList.jsx
index bcbcd28..513af54 100644
--- a/client/src/components/MessageList.jsx
+++ b/client/src/components/MessageList.jsx
@@ -35,10 +35,13 @@
dayjs.extend(isBetween);
export default function MessageList(props) {
- const messagesComponents = useMemo(() => buildMessagesList(props.messages), [props.messages]);
+ const messagesComponents = useMemo(
+ () => buildMessagesList(props.account, props.members, props.messages),
+ [props.account, props.members, props.messages]
+ );
return (
- <Stack marginLeft="16px" marginRight="16px" direction="column-reverse">
+ <Stack direction="column-reverse">
{messagesComponents?.map(({ Component, id, props }) => (
<Component key={id} {...props} />
))}
@@ -46,7 +49,7 @@
);
}
-const buildMessagesList = (messages) => {
+const buildMessagesList = (account, members, messages) => {
if (messages.length == 0) {
return null;
}
@@ -63,7 +66,7 @@
components.push({
id: `group-${messageBubblesGroup[0].id}`,
Component: MessageBubblesGroup,
- props: { messages: messageBubblesGroup },
+ props: { account, members, messages: messageBubblesGroup },
});
messageBubblesGroup = [];
};
diff --git a/client/src/components/SendMessageForm.tsx b/client/src/components/SendMessageForm.tsx
index f57ea1b..7230dd0 100644
--- a/client/src/components/SendMessageForm.tsx
+++ b/client/src/components/SendMessageForm.tsx
@@ -15,10 +15,13 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Divider, InputBase } from '@mui/material';
+import { InputBase } from '@mui/material';
import { Stack } from '@mui/system';
-import { ChangeEvent, FormEvent, useCallback, useState } from 'react';
+import { Account, ConversationMember } from 'jami-web-common';
+import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
import {
RecordVideoMessageButton,
RecordVoiceMessageButton,
@@ -28,11 +31,14 @@
} from './Button';
type SendMessageFormProps = {
+ account: Account;
+ members: ConversationMember[];
onSend: (message: string) => void;
};
export default function SendMessageForm(props: SendMessageFormProps) {
const [currentMessage, setCurrentMessage] = useState('');
+ const placeholder = usePlaceholder(props.account, props.members);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
@@ -49,43 +55,56 @@
);
return (
- <Stack padding="30px 16px 0px 16px">
- <Divider
- sx={{
- bordeTop: '1px solid #E5E5E5',
- }}
- />
- <Stack
- component="form"
- onSubmit={handleSubmit}
- direction="row"
- alignItems="center"
- flexGrow={1}
- spacing="20px"
- padding="16px 0px"
- >
- <UploadFileButton />
- <RecordVoiceMessageButton />
- <RecordVideoMessageButton />
-
- <Stack flexGrow={1}>
- <InputBase
- placeholder="Write something nice"
- value={currentMessage}
- onChange={handleInputChange}
- sx={{
- fontSize: '15px',
- color: 'black',
- '& ::placeholder': {
- color: '#7E7E7E',
- opacity: 1,
- },
- }}
- />
- </Stack>
- <SelectEmojiButton onEmojiSelected={onEmojiSelected} />
- {currentMessage && <SendMessageButton type="submit" />}
+ <Stack component="form" onSubmit={handleSubmit} direction="row" alignItems="center" spacing="20px">
+ <UploadFileButton />
+ <RecordVoiceMessageButton />
+ <RecordVideoMessageButton />
+ <Stack flexGrow={1}>
+ <InputBase
+ placeholder={placeholder}
+ value={currentMessage}
+ onChange={handleInputChange}
+ sx={{
+ fontSize: '15px',
+ color: 'black',
+ '& ::placeholder': {
+ color: '#7E7E7E',
+ opacity: 1,
+ textOverflow: 'ellipsis',
+ },
+ }}
+ />
</Stack>
+ <SelectEmojiButton onEmojiSelected={onEmojiSelected} />
+ {currentMessage && <SendMessageButton type="submit" />}
</Stack>
);
}
+
+const usePlaceholder = (account: Account, members: ConversationMember[]) => {
+ const { t } = useTranslation();
+
+ return useMemo(() => {
+ const options: TranslateEnumerationOptions<ConversationMember> = {
+ elementPartialKey: 'member',
+ getElementValue: (member) => getMemberName(member),
+ translaters: [
+ () =>
+ // The user is chatting with themself
+ t('message_input_placeholder_one', { member0: account?.getDisplayName() }),
+ (interpolations) => t('message_input_placeholder_one', interpolations),
+ (interpolations) => t('message_input_placeholder_two', interpolations),
+ (interpolations) => t('message_input_placeholder_three', interpolations),
+ (interpolations) => t('message_input_placeholder_four', interpolations),
+ (interpolations) => t('message_input_placeholder_more', interpolations),
+ ],
+ };
+
+ return translateEnumeration<ConversationMember>(members, options);
+ }, [account, members, t]);
+};
+
+const getMemberName = (member: ConversationMember) => {
+ const contact = member.contact;
+ return contact.getDisplayName();
+};
diff --git a/client/src/components/SvgIcon.tsx b/client/src/components/SvgIcon.tsx
index bfcb0a8..ba2a563 100644
--- a/client/src/components/SvgIcon.tsx
+++ b/client/src/components/SvgIcon.tsx
@@ -310,6 +310,14 @@
);
};
+export const ListIcon = (props: SvgIconProps) => {
+ return (
+ <SvgIcon {...props} viewBox="0 0 24 24">
+ <path d="M3.4 5.4C2.6 5.4 2 4.8 2 4v-.1c0-.7.6-1.3 1.3-1.3h.1c.7 0 1.3.6 1.3 1.3V4c.1.8-.5 1.4-1.3 1.4zM21 3H8.9c-.5 0-1 .4-1 1 0 .5.4 1 1 1H21c.5 0 1-.4 1-1s-.4-1-1-1zM3.4 13.4c-.8 0-1.4-.6-1.4-1.4 0-.7.6-1.3 1.3-1.3h.1c.7 0 1.3.6 1.3 1.3.1.8-.5 1.4-1.3 1.4zM21 13H8.9c-.5 0-1-.4-1-1 0-.5.4-1 1-1H21c.5 0 1 .4 1 1s-.4 1-1 1zM3.4 21.4c-.8 0-1.4-.6-1.4-1.3V20c0-.7.6-1.3 1.3-1.3h.1c.7 0 1.3.6 1.3 1.3v.1c.1.7-.5 1.3-1.3 1.3zM21 21H8.9c-.5 0-1-.5-1-1s.4-1 1-1H21c.5 0 1 .4 1 1s-.4 1-1 1z" />
+ </SvgIcon>
+ );
+};
+
export const LockIcon = (props: SvgIconProps) => {
return (
<SvgIcon {...props} viewBox="0 0 12.727 15.636">
@@ -420,6 +428,15 @@
);
};
+export const PeopleWithPlusSignIcon = (props: SvgIconProps) => {
+ return (
+ <SvgIcon {...props} viewBox="2 2 20 20">
+ <path d="M16.1 11.3c1.1-.7 1.8-2 1.8-3.3 0-2.2-1.8-4-4-4s-4 1.8-4 4c0 1.3.7 2.6 1.8 3.3-.6.3-1.2.6-1.8 1.1-.3-.3-.6-.5-1-.7.6-.6 1-1.4 1-2.3 0-1.8-1.4-3.2-3.2-3.2-1.8 0-3.2 1.4-3.2 3.2 0 .9.4 1.7 1 2.3C3 12.5 2 14.1 2 15.9c0 .6.5 1.1 1.1 1.1h4.7c0 .6.5 1.1 1.1 1.1h6.5l-.3-.2c-.3-.2-.5-.6-.7-1v-.1H9.1c0-.3.1-.7.1-1 .1-.6.4-1.1.7-1.6.2-.3.4-.6.7-.8.9-.8 2.1-1.3 3.3-1.3 1.1 0 2.1.4 3 1l.1.1.1-.1c.1-.2.2-.4.4-.6l.2-.2.1-.1-.1-.1c-.5-.3-1.1-.6-1.6-.8zm.5-3.4c0 1.5-1.2 2.7-2.7 2.7-1.5 0-2.7-1.2-2.7-2.7 0-1.5 1.2-2.7 2.7-2.7 1.5 0 2.7 1.2 2.7 2.7zm-10 3.4c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm2.4 2c-.5.7-.9 1.5-1.1 2.4H3.2c.1-1.8 1.6-3.3 3.4-3.3.9 0 1.8.3 2.4.9z" />
+ <path d="M21.2 15.6h-1.7v-1.7c0-.4-.3-.7-.7-.7-.2 0-.4.1-.5.2-.1.1-.2.3-.2.5v1.7h-1.7c-.2 0-.4.1-.5.2-.1.1-.2.3-.2.5 0 .4.3.7.7.7h1.7v1.7c0 .4.3.7.7.7.2 0 .4-.1.5-.2.1-.1.2-.3.2-.5V17h1.7c.2 0 .4-.1.5-.2.1-.1.2-.3.2-.5 0-.3-.3-.6-.7-.7z" />
+ </SvgIcon>
+ );
+};
+
export const PersonIcon = (props: SvgIconProps) => {
return (
<SvgIcon {...props} viewBox="0 0 24 24">
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 19ddf26..a7de038 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -1,4 +1,14 @@
{
+ "conversation_title_one": "{{member0}}",
+ "conversation_title_two": "{{member0}} and {{member1}}",
+ "conversation_title_three": "{{member0}}, {{member1}} and {{member2}}",
+ "conversation_title_four": "{{member0}}, {{member1}}, {{member2}}, +1 other member",
+ "conversation_title_more": "{{member0}}, {{member1}}, {{member2}}, +{{excess}} other members",
"message_swarm_created": "Swarm created",
- "message_user_joined": "{{user}} joined"
+ "message_user_joined": "{{user}} joined",
+ "message_input_placeholder_one": "Write to {{member0}}",
+ "message_input_placeholder_two": "Write to {{member0}} and {{member1}}",
+ "message_input_placeholder_three": "Write to {{member0}}, {{member1}} and {{member2}}",
+ "message_input_placeholder_four": "Write to {{member0}}, {{member1}}, {{member2}}, +1 other member",
+ "message_input_placeholder_more": "Write to {{member0}}, {{member1}}, {{member2}}, +{{excess}} other members"
}
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index 332c3d4..936d83d 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -1,4 +1,14 @@
{
+ "conversation_title_one": "{{member0}}",
+ "conversation_title_two": "{{member0}} et {{member1}}",
+ "conversation_title_three": "{{member0}}, {{member1}} et {{member2}}",
+ "conversation_title_four": "{{member0}}, {{member1}}, {{member2}}, +1 autre membre",
+ "conversation_title_more": "{{member01}}, {{member1}}, {{member2}}, +{{excess}} autres membres",
"message_swarm_created": "Le Swarm a été créé",
- "message_user_joined": "{{user}} s'est joint"
+ "message_user_joined": "{{user}} s'est joint",
+ "message_input_placeholder_one": "Écrire à {{member0}}",
+ "message_input_placeholder_two": "Écrire à {{member0}} et {{member1}}",
+ "message_input_placeholder_three": "Écrire à {{member0}}, {{member1}} et {{member2}}",
+ "message_input_placeholder_four": "Écrire à {{member0}}, {{member1}}, {{member2}}, +1 autre membre",
+ "message_input_placeholder_more": "Écrire à {{member01}}, {{member1}}, {{member2}}, +{{excess}} autres membres"
}
diff --git a/client/src/services/Account.ts b/client/src/services/Account.ts
new file mode 100644
index 0000000..a955883
--- /dev/null
+++ b/client/src/services/Account.ts
@@ -0,0 +1,27 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import axios from 'axios';
+
+export const useAccountQuery = (accountId: string) => {
+ return useQuery(['accounts', accountId], () => fetchAccount(accountId), {
+ enabled: !!accountId,
+ });
+};
+
+const fetchAccount = (accountId: string) => axios.get(`/api/accounts/${accountId}`).then((result) => result.data);
diff --git a/client/src/utils/translations.ts b/client/src/utils/translations.ts
new file mode 100644
index 0000000..4a559a5
--- /dev/null
+++ b/client/src/utils/translations.ts
@@ -0,0 +1,48 @@
+/*
+ * 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/>.
+ */
+
+export type Interpolations = Record<string, string>;
+
+export interface TranslateEnumerationOptions<T> {
+ // partial i18next interpolation key to which an index will be added
+ elementPartialKey: string;
+ // function to retrieve the i18next interpolation value
+ getElementValue: (element: T) => string;
+ // functions to translate the enumeration according to the number of elements
+ // The index of the function corresponds to the number of elements in the enumeration
+ // If the number of elements is higher than the number of functions, then the last function of the array will be used
+ translaters: ((interpolations: Interpolations) => string)[];
+}
+
+export const translateEnumeration = <T>(list: T[], options: TranslateEnumerationOptions<T>): string => {
+ const quantity = list.length;
+ const max = options.translaters.length;
+
+ const interpolations: Interpolations = {};
+
+ for (let i = 0; i < quantity && i < max; i++) {
+ const elementKey = `${options.elementPartialKey}${i}`;
+ interpolations[elementKey] = options.getElementValue(list[i]);
+ }
+
+ interpolations.excess = (max - quantity + 1).toString();
+
+ const translaterIndex = quantity <= max ? quantity : max;
+
+ return options.translaters[translaterIndex](interpolations);
+};