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);
+};