Improve styles for ConversationListItem

- Display last message
- Fix user talking to themself

Change-Id: Ia5bb3f9cd86a389f94bbfb3e279e7a82878f98ed
diff --git a/client/src/App.tsx b/client/src/App.tsx
index afd9817..dff99b7 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -15,6 +15,8 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
+import './dayjsInitializer'; // Initialized once and globally to ensure available locales are always imported
+
 import axios from 'axios';
 import { useState } from 'react';
 import { json, LoaderFunctionArgs, Outlet, redirect } from 'react-router-dom';
diff --git a/client/src/components/ConversationListItem.tsx b/client/src/components/ConversationListItem.tsx
index 710fe0b..985ee2c 100644
--- a/client/src/components/ConversationListItem.tsx
+++ b/client/src/components/ConversationListItem.tsx
@@ -15,7 +15,8 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Box, ListItem, ListItemAvatar, ListItemText } from '@mui/material';
+import { Box, ListItemButton, Stack, Typography } from '@mui/material';
+import dayjs from 'dayjs';
 import { IConversationSummary } from 'jami-web-common';
 import { QRCodeCanvas } from 'qrcode.react';
 import { useCallback, useContext, useMemo, useState } from 'react';
@@ -29,6 +30,8 @@
 import { setRefreshFromSlice } from '../redux/appSlice';
 import { useAppDispatch } from '../redux/hooks';
 import { ConversationRouteParams } from '../router';
+import { getMessageCallText, getMessageMemberText } from '../utils/chatmessages';
+import { formatRelativeDate, formatTime } from '../utils/dates&times';
 import ContextMenu, { ContextMenuHandler, useContextMenuHandler } from './ContextMenu';
 import ConversationAvatar from './ConversationAvatar';
 import { ConfirmationDialog, DialogContentList, InfosDialog, useDialogHandler } from './Dialog';
@@ -40,13 +43,14 @@
 };
 
 export default function ConversationListItem({ conversationSummary }: ConversationListItemProps) {
+  const { account } = useAuthContext();
   const {
     urlParams: { conversationId: selectedConversationId },
   } = useUrlParams<ConversationRouteParams>();
   const contextMenuHandler = useContextMenuHandler();
   const callContext = useCallContext(true);
   const { callData } = useContext(CallManagerContext);
-  const { t } = useTranslation();
+  const { t, i18n } = useTranslation();
   const navigate = useNavigate();
 
   const conversationId = conversationSummary.id;
@@ -58,9 +62,41 @@
     }
   }, [navigate, conversationId]);
 
-  const secondaryText = useMemo(() => {
+  const timeIndicator = useMemo(() => {
+    const message = conversationSummary.lastMessage;
+    const time = dayjs.unix(Number(message.timestamp));
+    if (time.isToday()) {
+      return formatTime(time, i18n);
+    } else {
+      return formatRelativeDate(time, i18n);
+    }
+  }, [conversationSummary, i18n]);
+
+  const lastMessageText = useMemo(() => {
     if (!callContext || !callData || callData.conversationId !== conversationSummary.id) {
-      return conversationSummary.lastMessage.body;
+      const message = conversationSummary.lastMessage;
+      switch (message.type) {
+        case 'initial': {
+          return t('message_swarm_created');
+        }
+        case 'application/data-transfer+json': {
+          return message.fileId;
+        }
+        case 'application/call-history+json': {
+          const isAccountMessage = message.author === account.getUri();
+          return getMessageCallText(isAccountMessage, message, i18n);
+        }
+        case 'member': {
+          return getMessageMemberText(message, i18n);
+        }
+        case 'text/plain': {
+          return message.body;
+        }
+        default: {
+          console.error(`${ConversationListItem.name} received an unexpected lastMessage type: ${message.type}`);
+          return '';
+        }
+      }
     }
 
     if (callContext.callStatus === CallStatus.InCall) {
@@ -72,11 +108,11 @@
     }
 
     return callContext.callRole === 'caller' ? t('outgoing_call') : t('incoming_call');
-  }, [conversationSummary, callContext, callData, t]);
+  }, [account, conversationSummary, callContext, callData, t, i18n]);
 
   const conversationName = useMemo(
-    () => conversationSummary.title ?? conversationSummary.membersNames.join(', '),
-    [conversationSummary]
+    () => conversationSummary.title || conversationSummary.membersNames.join(', ') || account.getDisplayName(),
+    [account, conversationSummary]
   );
 
   return (
@@ -88,18 +124,20 @@
         isSelected={isSelected}
         contextMenuProps={contextMenuHandler.props}
       />
-      <ListItem
-        button
-        alignItems="flex-start"
-        selected={isSelected}
-        onClick={onClick}
-        onContextMenu={contextMenuHandler.handleAnchorPosition}
-      >
-        <ListItemAvatar>
+      <ListItemButton alignItems="flex-start" selected={isSelected} onClick={onClick}>
+        <Stack direction="row" spacing="10px">
           <ConversationAvatar displayName={conversationName} />
-        </ListItemAvatar>
-        <ListItemText primary={conversationName} secondary={secondaryText} />
-      </ListItem>
+          <Stack>
+            <Typography variant="body1">{conversationName}</Typography>
+            <Stack direction="row" spacing="5px">
+              <Typography variant="body2" fontWeight={isSelected ? 'bold' : 'normal'}>
+                {timeIndicator}
+              </Typography>
+              <Typography variant="body2">{lastMessageText}</Typography>
+            </Stack>
+          </Stack>
+        </Stack>
+      </ListItemButton>
     </Box>
   );
 }
diff --git a/client/src/components/Message.tsx b/client/src/components/Message.tsx
index 8807295..a87d628 100644
--- a/client/src/components/Message.tsx
+++ b/client/src/components/Message.tsx
@@ -17,14 +17,15 @@
  */
 import { Box, Chip, Divider, Stack, Tooltip, Typography } from '@mui/material';
 import { styled } from '@mui/material/styles';
-import { Dayjs } from 'dayjs';
+import dayjs, { Dayjs } from 'dayjs';
 import { Message } from 'jami-web-common';
 import { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
-import dayjs from '../dayjsInitializer';
 import { Account } from '../models/account';
 import { Contact } from '../models/contact';
+import { getMessageCallText, getMessageMemberText } from '../utils/chatmessages';
+import { formatRelativeDate, formatTime } from '../utils/dates&times';
 import { EmojiButton, MoreButton, ReplyMessageButton } from './Button';
 import ConversationAvatar from './ConversationAvatar';
 import PopoverList, { PopoverListItemData } from './PopoverList';
@@ -93,48 +94,44 @@
 const MessageCall = ({ message, isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageCallProps) => {
   const position = isAccountMessage ? 'end' : 'start';
 
-  const { t } = useTranslation();
+  const { i18n } = useTranslation();
   const { bubbleColor, Icon, text, textColor } = useMemo(() => {
+    const text = getMessageCallText(isAccountMessage, message, i18n);
     const callDuration = dayjs.duration(parseInt(message?.duration || ''));
     if (callDuration.asSeconds() === 0) {
       if (isAccountMessage) {
         return {
-          text: t('message_call_outgoing_missed'),
+          text,
           Icon: ArrowLeftCurved,
           textColor: 'white',
           bubbleColor: '#005699' + '80', // opacity 50%
         };
       } else {
         return {
-          text: t('message_call_incoming_missed'),
+          text,
           Icon: ArrowLeftCurved,
           textColor: 'black',
           bubbleColor: '#C6C6C6',
         };
       }
     } else {
-      const minutes = Math.floor(callDuration.asMinutes()).toString().padStart(2, '0');
-      const seconds = callDuration.format('ss');
-      const interpolations = {
-        duration: `${minutes}:${seconds}`,
-      };
       if (isAccountMessage) {
         return {
-          text: t('message_call_outgoing', interpolations),
+          text,
           Icon: ArrowRightUp,
           textColor: 'white',
           bubbleColor: '#005699',
         };
       } else {
         return {
-          text: t('message_call_incoming', interpolations),
+          text,
           Icon: ArrowLeftDown,
           textcolor: 'black',
           bubbleColor: '#E5E5E5',
         };
       }
     }
-  }, [isAccountMessage, message, t]);
+  }, [isAccountMessage, message, i18n]);
 
   return (
     <Bubble position={position} isFirstOfGroup={isFirstOfGroup} isLastOfGroup={isLastOfGroup} bubbleColor={bubbleColor}>
@@ -174,13 +171,15 @@
 }
 
 const MessageMember = ({ message }: MessageMemberProps) => {
-  const { t } = useTranslation();
+  const { i18n } = useTranslation();
+
+  const text = getMessageMemberText(message, i18n);
   return (
     <Chip
       sx={{
         width: 'fit-content',
       }}
-      label={t('message_user_joined', { user: message.author })}
+      label={text}
     />
   );
 };
@@ -218,16 +217,7 @@
 
 const DateIndicator = ({ time }: DateIndicatorProps) => {
   const { i18n } = useTranslation();
-
-  const textDate = useMemo(() => {
-    if (time.isToday()) {
-      return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(0, 'day');
-    } else if (time.isYesterday()) {
-      return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(-1, 'day');
-    } else {
-      return dayjs(time).locale(i18n.language).format('L');
-    }
-  }, [i18n, time]);
+  const textDate = useMemo(() => formatRelativeDate(time, i18n), [time, i18n]);
 
   return (
     <Box marginTop="30px">
@@ -267,10 +257,7 @@
 
 const TimeIndicator = ({ time, hasDateOnTop }: TimeIndicatorProps) => {
   const { i18n } = useTranslation();
-
-  const textTime = useMemo(() => {
-    return dayjs(time).locale(i18n.language).format('LT');
-  }, [i18n, time]);
+  const textTime = useMemo(() => formatTime(time, i18n), [time, i18n]);
 
   return (
     <Stack direction="row" justifyContent="center" marginTop={hasDateOnTop ? '20px' : '30px'}>
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
index b6d2f4a..9e805e3 100644
--- a/client/src/contexts/ConversationProvider.tsx
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -63,7 +63,7 @@
   const conversationInfos = conversationInfosQuery.data;
   const members = membersQuery.data;
 
-  const conversationDisplayName = useConversationDisplayName(account, conversationId, conversationInfos, members);
+  const conversationDisplayName = useConversationDisplayName(account, conversationInfos, members);
 
   useEffect(() => {
     if (!conversationInfos || !conversationId || !webSocket) {
diff --git a/client/src/dayjsInitializer.ts b/client/src/dayjsInitializer.ts
index cff6ed1..6d01566 100644
--- a/client/src/dayjsInitializer.ts
+++ b/client/src/dayjsInitializer.ts
@@ -32,5 +32,3 @@
 dayjs.extend(isToday);
 dayjs.extend(isYesterday);
 dayjs.extend(localizedFormat);
-
-export default dayjs;
diff --git a/client/src/hooks/useConversationDisplayName.ts b/client/src/hooks/useConversationDisplayName.ts
index c8aabae..c668493 100644
--- a/client/src/hooks/useConversationDisplayName.ts
+++ b/client/src/hooks/useConversationDisplayName.ts
@@ -25,9 +25,8 @@
 
 export const useConversationDisplayName = (
   account: Account,
-  conversationId: string | undefined,
   conversationInfos: ConversationInfos | undefined,
-  members: ConversationMember[] | undefined
+  members: ConversationMember[] | undefined = []
 ) => {
   const { t } = useTranslation();
 
@@ -38,10 +37,6 @@
       return adminTitle;
     }
 
-    if (!members) {
-      return conversationId;
-    }
-
     const options: TranslateEnumerationOptions<ConversationMember> = {
       elementPartialKey: 'member',
       getElementValue: (member) => member.getDisplayName(),
@@ -58,5 +53,5 @@
     };
 
     return translateEnumeration<ConversationMember>(members, options);
-  }, [account, adminTitle, conversationId, members, t]);
+  }, [account, adminTitle, members, t]);
 };
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index 9634028..36d276f 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -1,6 +1,12 @@
 {
   "severity": "",
   "change_picture": "Change the picture",
+  "message_swarm_created": "Swarm created",
+  "message_call_outgoing_missed": "Missed outgoing call",
+  "message_call_incoming_missed": "Missed incoming call",
+  "message_call_outgoing": "Outgoing call - {{duration}}",
+  "message_call_incoming": "Incoming call - {{duration}}",
+  "message_member_joined": "{{user}} joined",
   "ongoing_call_unmuted": "Ongoing call",
   "ongoing_call_muted": "Ongoing call (muted)",
   "connecting_call": "Connecting...",
@@ -11,7 +17,6 @@
   "conversation_start_videocall": "Start video call",
   "conversation_close": "Close this conversation",
   "conversation_details": "Conversation details",
-  "conversation_block": "Block conversation",
   "conversation_delete": "Remove conversation",
   "conversation_details_name": "Title",
   "conversation_details_identifier": "Identifier",
@@ -21,15 +26,8 @@
   "conversation_details_is_swarm_false": "False",
   "conversation_details_informations": "Informations",
   "dialog_confirm_title_default": "Confirm action",
-  "conversation_ask_confirm_block": "Would you really like to block this conversation?",
-  "conversation_confirm_block": "Block",
   "conversation_ask_confirm_remove": "Would you really like to remove this conversation?",
   "conversation_confirm_remove": "Remove",
-  "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",
   "select_placeholder": "Select an option",
   "dialog_close": "Close",
   "dialog_cancel": "Cancel",
@@ -62,12 +60,10 @@
   "username_rule_three": "The username may contain hyphens (-).",
   "username_rule_four": "The username may contain underscores (_).",
   "welcome_text": "Welcome to Jami!",
-  "message_call_outgoing_missed": "Missed outgoing call",
-  "message_call_incoming_missed": "Missed incoming call",
-  "message_call_outgoing": "Outgoing call - {{duration}}",
-  "message_call_incoming": "Incoming call - {{duration}}",
-  "message_swarm_created": "Swarm created",
-  "message_user_joined": "{{user}} joined",
+  "message_member_invited": "{{user}} was invited to join",
+  "message_member_left": "{{user}} left",
+  "message_member_banned": "{{user}} was kicked",
+  "message_member_unbanned": "{{user}} was re-added",
   "messages_scroll_to_end": "Scroll to end of conversation",
   "conversation_add_contact_form": "Add a contact",
   "message_input_placeholder_one": "Write to {{member0}}",
@@ -76,6 +72,11 @@
   "message_input_placeholder_four": "Write to {{member0}}, {{member1}}, {{member2}}, +1 other member",
   "message_input_placeholder_more": "Write to {{member0}}, {{member1}}, {{member2}}, +{{excess}} other members",
   "missed_incoming_call": "Missed incoming call from conversation {{conversationId}}",
+  "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",
   "conversation_add_contact": "Add contact",
   "loading": "Loading...",
   "calling": "Calling {{member0}}",
@@ -115,6 +116,9 @@
   "setup_login_password_placeholder_creation": "New password",
   "setup_login_password_placeholder_repeat": "Repeat password",
   "admin_creation_submit_button": "Create admin account",
+  "conversation_block": "Block conversation",
+  "conversation_ask_confirm_block": "Would you really like to block this conversation?",
+  "conversation_confirm_block": "Block",
   "share_screen": "Share your screen",
   "share_window": "Share window",
   "share_screen_area": "Share an area of your screen",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index 44d0517..9b8ad6d 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -1,6 +1,7 @@
 {
   "severity": "",
   "change_picture": "Modifier l'image",
+  "message_swarm_created": "Le Swarm a été créé",
   "ongoing_call_unmuted": "Appel en cours",
   "ongoing_call_muted": "Appel en cours (muet)",
   "connecting_call": "Connexion...",
@@ -11,7 +12,6 @@
   "conversation_start_videocall": "Démarrer appel vidéo",
   "conversation_close": "Fermer la conversation",
   "conversation_details": "Détails de la conversation",
-  "conversation_block": "Bloquer la conversation",
   "conversation_delete": "Supprimer la conversation",
   "conversation_details_name": "Titre",
   "conversation_details_identifier": "Identifiant",
@@ -21,15 +21,8 @@
   "conversation_details_is_swarm_false": "Non",
   "conversation_details_informations": "Informations",
   "dialog_confirm_title_default": "Merci de confirmer",
-  "conversation_ask_confirm_block": "Souhaitez-vous vraiment bloquer cette conversation ?",
-  "conversation_confirm_block": "Bloquer",
   "conversation_ask_confirm_remove": "Souhaitez-vous vraiment supprimer cette conversation ?",
   "conversation_confirm_remove": "Supprimer",
-  "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": "{{member0}}, {{member1}}, {{member2}}, +{{excess}} autres membres",
   "select_placeholder": "Sélectionner une option",
   "dialog_close": "Fermer",
   "dialog_cancel": "Annuler",
@@ -62,12 +55,6 @@
   "username_rule_three": "Le nom d'utilisateur peut contenir des tirets (-).",
   "username_rule_four": "Le nom d'utilisateur peut contenir des tirets bas (_).",
   "welcome_text": "Bienvenue sur Jami!",
-  "message_call_outgoing_missed": "Appel sortant manqué",
-  "message_call_incoming_missed": "Appel entrant manqué",
-  "message_call_outgoing": "Appel entrant - {{duration}}",
-  "message_call_incoming": "Appel sortant - {{duration}}",
-  "message_swarm_created": "Le Swarm a été créé",
-  "message_user_joined": "{{user}} s'est joint",
   "messages_scroll_to_end": "Faire défiler jusqu'à la fin de la conversation",
   "conversation_add_contact_form": "Ajouter un contact",
   "message_input_placeholder_one": "Écrire à {{member0}}",
@@ -76,6 +63,11 @@
   "message_input_placeholder_four": "Écrire à {{member0}}, {{member1}}, {{member2}}, +1 autre membre",
   "message_input_placeholder_more": "Écrire à {{member0}}, {{member1}}, {{member2}}, +{{excess}} autres membres",
   "missed_incoming_call": "Appel manqué de la conversation {{conversationId}}",
+  "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": "{{member0}}, {{member1}}, {{member2}}, +{{excess}} autres membres",
   "conversation_add_contact": "Ajouter le contact",
   "loading": "Chargement...",
   "calling": "Appel vers {{member0}}",
@@ -115,6 +107,18 @@
   "setup_login_password_placeholder_creation": "Nouveau mot de passe",
   "setup_login_password_placeholder_repeat": "Répéter le mot de passe",
   "admin_creation_submit_button": "Créer un compte admin",
+  "message_call_outgoing_missed": "Appel sortant manqué",
+  "message_call_incoming_missed": "Appel entrant manqué",
+  "message_call_outgoing": "Appel entrant - {{duration}}",
+  "message_call_incoming": "Appel sortant - {{duration}}",
+  "message_member_invited": "{{user}} a été invité(e)",
+  "message_member_joined": "{{user}} s'est joint(e)",
+  "message_member_left": "{{user}} a quitté",
+  "message_member_banned": "{{user}} a été exclu(e)",
+  "message_member_unbanned": "{{user}} a été de nouveau rajouté(e)",
+  "conversation_block": "Bloquer la conversation",
+  "conversation_ask_confirm_block": "Souhaitez-vous vraiment bloquer cette conversation ?",
+  "conversation_confirm_block": "Bloquer",
   "share_screen": "Partager votre écran",
   "share_window": "Partager la fenêtre",
   "share_screen_area": "Partager une partie de l'écran",
diff --git a/client/src/models/account.ts b/client/src/models/account.ts
index ae2299e..e616ae1 100644
--- a/client/src/models/account.ts
+++ b/client/src/models/account.ts
@@ -70,10 +70,10 @@
   }
 
   getDisplayName() {
-    return this.details['Account.displayName'] ?? this.getDisplayUri();
+    return this.details['Account.displayName'] || this.getDisplayUri();
   }
 
   getDisplayNameNoFallback() {
-    return this.details['Account.displayName'] ?? this.getRegisteredName();
+    return this.details['Account.displayName'] || this.getRegisteredName();
   }
 }
diff --git a/client/src/pages/CallInterface.tsx b/client/src/pages/CallInterface.tsx
index b535b25..2425b52 100644
--- a/client/src/pages/CallInterface.tsx
+++ b/client/src/pages/CallInterface.tsx
@@ -16,6 +16,8 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Box, Card, Grid, Stack, Typography } from '@mui/material';
+import dayjs from 'dayjs';
+import { Duration } from 'dayjs/plugin/duration';
 import {
   ComponentType,
   Fragment,
@@ -49,6 +51,7 @@
 import { CallStatus, useCallContext, VideoStatus } from '../contexts/CallProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { useWebRtcContext } from '../contexts/WebRtcProvider';
+import { formatCallDuration } from '../utils/dates&times';
 import { VideoElementWithSinkId } from '../utils/utils';
 import { CallPending } from './CallPending';
 import CallPermissionDenied from './CallPermissionDenied';
@@ -151,38 +154,23 @@
   );
 };
 
-const formatElapsedSeconds = (elapsedSeconds: number): string => {
-  const seconds = Math.floor(elapsedSeconds % 60);
-  elapsedSeconds = Math.floor(elapsedSeconds / 60);
-  const minutes = elapsedSeconds % 60;
-  elapsedSeconds = Math.floor(elapsedSeconds / 60);
-  const hours = elapsedSeconds % 24;
-
-  const times: string[] = [];
-  if (hours > 0) {
-    times.push(hours.toString().padStart(2, '0'));
-  }
-  times.push(minutes.toString().padStart(2, '0'));
-  times.push(seconds.toString().padStart(2, '0'));
-
-  return times.join(':');
-};
-
 const CallInterfaceInformation = () => {
   const { callStartTime } = useCallContext();
   const { conversationDisplayName } = useConversationContext();
-  const [elapsedTime, setElapsedTime] = useState(callStartTime ? (Date.now() - callStartTime) / 1000 : 0);
+  const [elapsedTime, setElapsedTime] = useState<Duration>(
+    dayjs.duration(callStartTime ? Date.now() - callStartTime : 0)
+  );
 
   useEffect(() => {
     if (callStartTime) {
       const interval = setInterval(() => {
-        setElapsedTime((Date.now() - callStartTime) / 1000);
+        setElapsedTime(dayjs.duration(Date.now() - callStartTime));
       }, 1000);
       return () => clearInterval(interval);
     }
   }, [callStartTime]);
 
-  const elapsedTimerString = formatElapsedSeconds(elapsedTime);
+  const elapsedTimerString = formatCallDuration(elapsedTime);
 
   return (
     <Stack direction="row" justifyContent="space-between" alignItems="center">
diff --git a/client/src/themes/Default.ts b/client/src/themes/Default.ts
index a3f1b86..a29c68d 100644
--- a/client/src/themes/Default.ts
+++ b/client/src/themes/Default.ts
@@ -392,6 +392,24 @@
           },
         },
       },
+      MuiListItemButton: {
+        styleOverrides: {
+          root: {
+            '&.Mui-selected': {
+              backgroundColor: '#cccccc',
+              opacity: 1,
+              '&:hover': {
+                backgroundColor: '#cccccc',
+                opacity: 1,
+              },
+            },
+            '&:hover': {
+              backgroundColor: '#d5d5d5',
+              opacity: 1,
+            },
+          },
+        },
+      },
     },
   });
 };
diff --git a/client/src/utils/chatmessages.ts b/client/src/utils/chatmessages.ts
new file mode 100644
index 0000000..53c6053
--- /dev/null
+++ b/client/src/utils/chatmessages.ts
@@ -0,0 +1,57 @@
+/*
+ * 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 dayjs from 'dayjs';
+import { i18n } from 'i18next';
+import { Message } from 'jami-web-common';
+
+import { formatCallDuration } from './dates&times';
+
+export const getMessageCallText = (isAccountMessage: boolean, message: Message, i18n: i18n) => {
+  const callDuration = dayjs.duration(parseInt(message?.duration || ''));
+  const formattedCallDuration = formatCallDuration(callDuration);
+  if (callDuration.asSeconds() === 0) {
+    if (isAccountMessage) {
+      return i18n.t('message_call_outgoing_missed');
+    } else {
+      return i18n.t('message_call_incoming_missed');
+    }
+  } else {
+    if (isAccountMessage) {
+      return i18n.t('message_call_outgoing', formattedCallDuration);
+    } else {
+      return i18n.t('message_call_incoming', formattedCallDuration);
+    }
+  }
+};
+
+export const getMessageMemberText = (message: Message, i18n: i18n) => {
+  switch (message.action) {
+    case 'add':
+      return i18n.t('message_member_invited', { user: message.author });
+    case 'join':
+      return i18n.t('message_member_joined', { user: message.author });
+    case 'remove':
+      return i18n.t('message_member_left', { user: message.author });
+    case 'ban':
+      return i18n.t('message_member_banned', { user: message.author });
+    case 'unban':
+      return i18n.t('message_member_unbanned', { user: message.author });
+    default:
+      console.error(`${getMessageMemberText.name} received an unexpected message action: ${message.action}`);
+  }
+};
diff --git a/client/src/utils/dates&times.ts b/client/src/utils/dates&times.ts
new file mode 100644
index 0000000..d95d1ac
--- /dev/null
+++ b/client/src/utils/dates&times.ts
@@ -0,0 +1,40 @@
+/*
+ * 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 dayjs, { Dayjs } from 'dayjs';
+import { Duration } from 'dayjs/plugin/duration';
+import { i18n } from 'i18next';
+
+export const formatTime = (time: Dayjs, i18n: i18n) => {
+  return dayjs(time).locale(i18n.language).format('LT');
+};
+
+export const formatRelativeDate = (time: Dayjs, i18n: i18n) => {
+  if (time.isToday()) {
+    return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(0, 'day');
+  } else if (time.isYesterday()) {
+    return new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto' }).format(-1, 'day');
+  } else {
+    return dayjs(time).locale(i18n.language).format('L');
+  }
+};
+
+export const formatCallDuration = (duration: Duration) => {
+  const minutes = Math.floor(duration.asMinutes()).toString().padStart(2, '0');
+  const seconds = duration.format('ss');
+  return `${minutes}:${seconds}`;
+};
diff --git a/common/src/interfaces/conversation.ts b/common/src/interfaces/conversation.ts
index dab9dd0..f49f72e 100644
--- a/common/src/interfaces/conversation.ts
+++ b/common/src/interfaces/conversation.ts
@@ -39,10 +39,12 @@
     | 'vote';
   linearizedParent: string;
   parents: string;
+  action?: 'add' | 'join' | 'remove' | 'ban' | 'unban';
   body?: string;
   duration?: string;
   to?: string;
   invited?: string;
+  fileId?: string;
 }
 
 export interface ConversationInfos {
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index 2476e72..9c1258d 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -482,7 +482,7 @@
     this.events.onConversationLoaded.subscribe(({ id, accountId, conversationId }) => {
       log.debug(
         `Received ConversationLoaded: {"id":"${id}","accountId":"${accountId}",` +
-          `"conversationId":"${conversationId}","messages":[...]}`
+          `"conversationId":"${conversationId}"}`
       );
     });
 
diff --git a/server/src/routers/conversation-router.ts b/server/src/routers/conversation-router.ts
index 0a6b0b2..aa65723 100644
--- a/server/src/routers/conversation-router.ts
+++ b/server/src/routers/conversation-router.ts
@@ -23,6 +23,7 @@
   HttpStatusCode,
   IConversationMember,
   IConversationSummary,
+  Message,
   NewConversationRequestBody,
   NewMessageRequestBody,
 } from 'jami-web-common';
@@ -54,10 +55,17 @@
 
     // Add usernames for conversation members
     const { username } = await jamid.lookupAddress(member.uri, accountId);
-    membersNames.push(username ?? member.uri);
+    membersNames.push(username || member.uri);
   }
 
-  const lastMessage = (await jamid.getConversationMessages(accountId, conversationId, '', 1))[0];
+  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,