review whole logic for displaying messages

Get rid of buildMessageList and MessageBubblesGroup.
Better handling of styles and informations (time) conditional to previous and next messages.
Should help to implement "compact" view.

Change-Id: I78fca656e7ad769f2efb3ee21ef1ba719f7919ec
diff --git a/client/src/components/Message.tsx b/client/src/components/Message.tsx
index bfc442f..981d3d5 100644
--- a/client/src/components/Message.tsx
+++ b/client/src/components/Message.tsx
@@ -29,47 +29,107 @@
 } from '@mui/material';
 import { styled } from '@mui/material/styles';
 import dayjs, { Dayjs } from 'dayjs';
+import dayOfYear from 'dayjs/plugin/dayOfYear';
+import isBetween from 'dayjs/plugin/isBetween';
 import isToday from 'dayjs/plugin/isToday';
 import isYesterday from 'dayjs/plugin/isYesterday';
-import { Account, ConversationMember, Message } from 'jami-web-common';
-import { ReactElement } from 'react';
-import { ReactNode, useCallback, useMemo, useState } from 'react';
+import { Account, Contact, Message } from 'jami-web-common';
+import { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { EmojiButton, MoreButton, ReplyMessageButton } from './Button';
 import ConversationAvatar from './ConversationAvatar';
 import { OppositeArrowsIcon, TrashBinIcon, TwoSheetsIcon } from './SvgIcon';
 
+dayjs.extend(dayOfYear);
+dayjs.extend(isBetween);
 dayjs.extend(isToday);
 dayjs.extend(isYesterday);
 
 type MessagePosition = 'start' | 'end';
 
-export const MessageCall = () => {
-  return <Stack alignItems="center">&quot;Appel&quot;</Stack>;
+const notificationMessageTypes = ['initial', 'member'] as const;
+type NotificationMessageType = typeof notificationMessageTypes[number];
+const checkIsNotificationMessageType = (type: Message['type'] | undefined): type is NotificationMessageType => {
+  return notificationMessageTypes.includes(type as NotificationMessageType);
 };
 
-export const MessageInitial = () => {
-  const { t } = useTranslation();
-  return <Stack alignItems="center">{t('message_swarm_created')}</Stack>;
+const invisibleMessageTypes = ['application/update-profile', 'merge', 'vote'] as const;
+type InvisibleMessageType = typeof invisibleMessageTypes[number];
+const checkIsInvisibleMessageType = (type: Message['type'] | undefined): type is InvisibleMessageType => {
+  return invisibleMessageTypes.includes(type as InvisibleMessageType);
 };
 
-interface MessageDataTransferProps {
-  position: MessagePosition;
+const userMessageTypes = ['text/plain', 'application/data-transfer+json', 'application/call-history+json'] as const;
+type UserMessageType = typeof userMessageTypes[number];
+const checkIsUserMessageType = (type: Message['type'] | undefined): type is UserMessageType => {
+  return userMessageTypes.includes(type as UserMessageType);
+};
+
+const checkShowsTime = (time: Dayjs, previousTime: Dayjs) => {
+  return !previousTime.isSame(time) && !time.isBetween(previousTime, previousTime?.add(1, 'minute'));
+};
+
+const findPreviousVisibleMessage = (messages: Message[], messageIndex: number) => {
+  for (let i = messageIndex + 1; i < messages.length; ++i) {
+    const message = messages[i];
+    if (!checkIsInvisibleMessageType(message?.type)) {
+      return message;
+    }
+  }
+};
+
+const findNextVisibleMessage = (messages: Message[], messageIndex: number) => {
+  for (let i = messageIndex - 1; i >= 0; --i) {
+    const message = messages[i];
+    if (!checkIsInvisibleMessageType(message?.type)) {
+      return message;
+    }
+  }
+};
+
+const avatarSize = '22px';
+const spacingBetweenAvatarAndBubble = '10px';
+const bubblePadding = '16px';
+
+interface MessageCallProps {
+  message: Message;
+  isAccountMessage: boolean;
   isFirstOfGroup: boolean;
   isLastOfGroup: boolean;
 }
 
-export const MessageDataTransfer = ({ position, isFirstOfGroup, isLastOfGroup }: MessageDataTransferProps) => {
+const MessageCall = ({ isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageCallProps) => {
+  const position = isAccountMessage ? 'end' : 'start';
+  const bubbleColor = isAccountMessage ? '#005699' : '#E5E5E5';
+  const textColor = isAccountMessage ? 'white' : 'black';
   return (
-    <MessageBubble
-      backgroundColor={'#E5E5E5'}
-      position={position}
-      isFirstOfGroup={isFirstOfGroup}
-      isLastOfGroup={isLastOfGroup}
-    >
+    <Bubble position={position} isFirstOfGroup={isFirstOfGroup} isLastOfGroup={isLastOfGroup} bubbleColor={bubbleColor}>
+      <Typography variant="body1" color={textColor} textAlign={position}>
+        &quot;Appel&quot;
+      </Typography>
+    </Bubble>
+  );
+};
+
+const MessageInitial = () => {
+  const { t } = useTranslation();
+  return <>{t('message_swarm_created')}</>;
+};
+
+interface MessageDataTransferProps {
+  message: Message;
+  isAccountMessage: boolean;
+  isFirstOfGroup: boolean;
+  isLastOfGroup: boolean;
+}
+
+const MessageDataTransfer = ({ isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageDataTransferProps) => {
+  const position = isAccountMessage ? 'end' : 'start';
+  return (
+    <Bubble bubbleColor="#E5E5E5" position={position} isFirstOfGroup={isFirstOfGroup} isLastOfGroup={isLastOfGroup}>
       &quot;data-transfer&quot;
-    </MessageBubble>
+    </Bubble>
   );
 };
 
@@ -77,60 +137,50 @@
   message: Message;
 }
 
-export const MessageMember = ({ message }: MessageMemberProps) => {
+const MessageMember = ({ message }: MessageMemberProps) => {
   const { t } = useTranslation();
   return (
-    <Stack alignItems="center">
-      <Chip
-        sx={{
-          width: 'fit-content',
-        }}
-        label={t('message_user_joined', { user: message.author })}
-      />
-    </Stack>
+    <Chip
+      sx={{
+        width: 'fit-content',
+      }}
+      label={t('message_user_joined', { user: message.author })}
+    />
   );
 };
 
-export const MessageMerge = () => {
-  return <Stack alignItems="center">&quot;merge&quot;</Stack>;
-};
-
 interface MessageTextProps {
   message: Message;
-  position: MessagePosition;
+  isAccountMessage: boolean;
   isFirstOfGroup: boolean;
   isLastOfGroup: boolean;
-  textColor: string;
-  bubbleColor: string;
 }
 
-export const MessageText = ({
-  message,
-  position,
-  isFirstOfGroup,
-  isLastOfGroup,
-  textColor,
-  bubbleColor,
-}: MessageTextProps) => {
+const MessageText = ({ message, isAccountMessage, isFirstOfGroup, isLastOfGroup }: MessageTextProps) => {
+  const position = isAccountMessage ? 'end' : 'start';
+  const bubbleColor = isAccountMessage ? '#005699' : '#E5E5E5';
+  const textColor = isAccountMessage ? 'white' : 'black';
   return (
-    <MessageBubble
-      backgroundColor={bubbleColor}
-      position={position}
-      isFirstOfGroup={isFirstOfGroup}
-      isLastOfGroup={isLastOfGroup}
-    >
-      <Typography variant="body1" color={textColor} textAlign={position}>
-        {message.body}
-      </Typography>
-    </MessageBubble>
+    <MessageTooltip position={position}>
+      <Bubble
+        bubbleColor={bubbleColor}
+        position={position}
+        isFirstOfGroup={isFirstOfGroup}
+        isLastOfGroup={isLastOfGroup}
+      >
+        <Typography variant="body1" color={textColor} textAlign={position}>
+          {message.body}
+        </Typography>
+      </Bubble>
+    </MessageTooltip>
   );
 };
 
-interface MessageDateProps {
+interface DateIndicatorProps {
   time: Dayjs;
 }
 
-export const MessageDate = ({ time }: MessageDateProps) => {
+const DateIndicator = ({ time }: DateIndicatorProps) => {
   let textDate;
 
   if (time.isToday()) {
@@ -173,18 +223,18 @@
   );
 };
 
-interface MessageTimeProps {
+interface TimeIndicatorProps {
   time: Dayjs;
   hasDateOnTop: boolean;
 }
 
-export const MessageTime = ({ time, hasDateOnTop }: MessageTimeProps) => {
+const TimeIndicator = ({ time, hasDateOnTop }: TimeIndicatorProps) => {
   const hour = time.hour().toString().padStart(2, '0');
   const minute = time.minute().toString().padStart(2, '0');
   const textTime = `${hour}:${minute}`;
 
   return (
-    <Stack direction="row" justifyContent="center" margin="30px" marginTop={hasDateOnTop ? '20px' : '30px'}>
+    <Stack direction="row" justifyContent="center" marginTop={hasDateOnTop ? '20px' : '30px'}>
       <Typography variant="caption" color="#A7A7A7" fontWeight={700}>
         {textTime}
       </Typography>
@@ -192,71 +242,111 @@
   );
 };
 
-interface MessageBubblesGroupProps {
-  account: Account;
-  messages: Message[];
-  members: ConversationMember[];
+interface NotificationMessageRowProps {
+  message: Message;
 }
 
-export const MessageBubblesGroup = ({ account, messages, members }: MessageBubblesGroupProps) => {
-  const isUser = messages[0]?.author === account.getUri();
-  const position = isUser ? 'end' : 'start';
-  const bubbleColor = isUser ? '#005699' : '#E5E5E5';
-  const textColor = isUser ? 'white' : 'black';
-
-  let authorName;
-  if (isUser) {
-    authorName = account.getDisplayName();
-  } else {
-    const member = members.find((member) => messages[0]?.author === member.contact.getUri());
-    authorName = member?.contact?.getDisplayName() || '';
+const NotificationMessageRow = ({ message }: NotificationMessageRowProps) => {
+  let messageComponent;
+  switch (message.type) {
+    case 'initial':
+      messageComponent = <MessageInitial />;
+      break;
+    case 'member':
+      messageComponent = <MessageMember message={message} />;
+      break;
+    default:
+      console.error(`${NotificationMessageRow.name} received unhandled message type: ${message.type}`);
+      return null;
   }
 
   return (
-    <Stack // Row for a group of message bubbles with the user's infos
-      direction="row"
-      justifyContent={position}
-      alignItems="end"
-      spacing="10px"
-    >
-      {!isUser && (
-        <ConversationAvatar displayName={authorName} sx={{ width: '22px', height: '22px', fontSize: '15px' }} />
+    <Stack paddingTop={'30px'} alignItems="center">
+      {messageComponent}
+    </Stack>
+  );
+};
+
+interface UserMessageRowProps {
+  message: Message;
+  isAccountMessage: boolean;
+  previousMessage: Message | undefined;
+  nextMessage: Message | undefined;
+  time: Dayjs;
+  showsTime: boolean;
+  author: Account | Contact;
+}
+
+const UserMessageRow = ({
+  message,
+  previousMessage,
+  nextMessage,
+  isAccountMessage,
+  time,
+  showsTime,
+  author,
+}: UserMessageRowProps) => {
+  const authorName = author.getDisplayName();
+  const position = isAccountMessage ? 'end' : 'start';
+
+  const previousIsUserMessageType = checkIsUserMessageType(previousMessage?.type);
+  const nextIsUserMessageType = checkIsUserMessageType(nextMessage?.type);
+  const nextTime = dayjs.unix(Number(nextMessage?.timestamp));
+  const nextShowsTime = checkShowsTime(nextTime, time);
+  const isFirstOfGroup = showsTime || !previousIsUserMessageType || previousMessage?.author !== message.author;
+  const isLastOfGroup = nextShowsTime || !nextIsUserMessageType || message.author !== nextMessage?.author;
+
+  const props = {
+    message,
+    isAccountMessage,
+    isFirstOfGroup,
+    isLastOfGroup,
+  };
+
+  let MessageComponent;
+  switch (message.type) {
+    case 'text/plain':
+      MessageComponent = MessageText;
+      break;
+    case 'application/data-transfer+json':
+      MessageComponent = MessageDataTransfer;
+      break;
+    case 'application/call-history+json':
+      MessageComponent = MessageCall;
+      break;
+    default:
+      console.error(`${UserMessageRow.name} received unhandled message type: ${message.type}`);
+      return null;
+  }
+
+  const participantNamePadding = isAccountMessage
+    ? bubblePadding
+    : parseInt(avatarSize) + parseInt(spacingBetweenAvatarAndBubble) + parseInt(bubblePadding) + 'px';
+
+  return (
+    <Stack alignItems={position}>
+      {isFirstOfGroup && (
+        <Box padding={`30px ${participantNamePadding} 0 ${participantNamePadding}`}>
+          <ParticipantName name={authorName} />
+        </Box>
       )}
-      <Stack // Container to align the bubbles to the same side of a row
+      <Stack
+        direction="row"
+        justifyContent={position}
+        alignItems="end"
+        spacing={spacingBetweenAvatarAndBubble}
+        paddingTop="6px"
         width="66.66%"
-        alignItems={position}
       >
-        <ParticipantName name={authorName} />
-        <Stack // Container for a group of message bubbles
-          spacing="6px"
-          alignItems={position}
-          direction="column-reverse"
-        >
-          {messages.map((message, index) => {
-            let Component: typeof MessageText | typeof MessageDataTransfer;
-            switch (message.type) {
-              case 'text/plain':
-                Component = MessageText;
-                break;
-              case 'application/data-transfer+json':
-                Component = MessageDataTransfer;
-                break;
-              default:
-                return null;
-            }
-            return (
-              <Component // Single message
-                key={message.id}
-                message={message}
-                textColor={textColor}
-                position={position}
-                bubbleColor={bubbleColor}
-                isFirstOfGroup={index === messages.length - 1}
-                isLastOfGroup={index === 0}
-              />
-            );
-          })}
-        </Stack>
+        <Box sx={{ width: avatarSize }}>
+          {!isAccountMessage && isLastOfGroup && (
+            <ConversationAvatar
+              displayName={authorName}
+              sx={{ width: avatarSize, height: avatarSize, fontSize: '15px' }}
+            />
+          )}
+        </Box>
+        <MessageComponent {...props} />
       </Stack>
     </Stack>
   );
@@ -365,7 +455,8 @@
         </Stack>
       }
     >
-      {children}
+      {/* div fixes 'Function components cannot be given refs' error */}
+      <div>{children}</div>
     </Tooltip>
   );
 })(({ position }) => {
@@ -381,15 +472,15 @@
   };
 });
 
-interface MessageBubbleProps {
+interface BubbleProps {
   position: MessagePosition;
   isFirstOfGroup: boolean;
   isLastOfGroup: boolean;
-  backgroundColor: string;
+  bubbleColor: string;
   children: ReactNode;
 }
 
-const MessageBubble = ({ position, isFirstOfGroup, isLastOfGroup, backgroundColor, children }: MessageBubbleProps) => {
+const Bubble = ({ position, isFirstOfGroup, isLastOfGroup, bubbleColor, children }: BubbleProps) => {
   const largeRadius = '20px';
   const smallRadius = '5px';
   const radius = useMemo(() => {
@@ -410,18 +501,16 @@
   }, [isFirstOfGroup, isLastOfGroup, position]);
 
   return (
-    <MessageTooltip position={position}>
-      <Box
-        sx={{
-          width: 'fit-content',
-          backgroundColor: backgroundColor,
-          padding: '16px',
-          ...radius,
-        }}
-      >
-        {children}
-      </Box>
-    </MessageTooltip>
+    <Box
+      sx={{
+        width: 'fit-content',
+        backgroundColor: bubbleColor,
+        padding: bubblePadding,
+        ...radius,
+      }}
+    >
+      {children}
+    </Box>
   );
 };
 
@@ -431,10 +520,55 @@
 
 const ParticipantName = ({ name }: ParticipantNameProps) => {
   return (
-    <Box marginBottom="6px" marginLeft="16px" marginRight="16px">
-      <Typography variant="caption" color="#A7A7A7" fontWeight={700}>
-        {name}
-      </Typography>
-    </Box>
+    <Typography variant="caption" color="#A7A7A7" fontWeight={700}>
+      {name}
+    </Typography>
+  );
+};
+
+interface MessageProps {
+  messageIndex: number;
+  messages: Message[];
+  isAccountMessage: boolean;
+  author: Account | Contact;
+}
+
+export const MessageRow = ({ messageIndex, messages, isAccountMessage, author }: MessageProps) => {
+  const message = messages[messageIndex];
+  const previousMessage = findPreviousVisibleMessage(messages, messageIndex);
+  const nextMessage = findNextVisibleMessage(messages, messageIndex);
+  const time = dayjs.unix(Number(message.timestamp));
+  const previousTime = dayjs.unix(Number(previousMessage?.timestamp));
+  const showDate =
+    message?.type === 'initial' || previousTime.year() !== time.year() || previousTime.dayOfYear() !== time.dayOfYear();
+  const showTime = checkShowsTime(time, previousTime);
+  let messageComponent;
+  if (checkIsUserMessageType(message.type)) {
+    messageComponent = (
+      <UserMessageRow
+        message={message}
+        previousMessage={previousMessage}
+        nextMessage={nextMessage}
+        time={time}
+        showsTime={showTime}
+        isAccountMessage={isAccountMessage}
+        author={author}
+      />
+    );
+  } else if (checkIsNotificationMessageType(message.type)) {
+    messageComponent = <NotificationMessageRow message={message} />;
+  } else if (checkIsInvisibleMessageType(message.type)) {
+    return null;
+  } else {
+    const _exhaustiveCheck: never = message.type;
+    return _exhaustiveCheck;
+  }
+
+  return (
+    <Stack>
+      {showDate && <DateIndicator time={time} />}
+      {showTime && <TimeIndicator time={time} hasDateOnTop={showDate} />}
+      {messageComponent}
+    </Stack>
   );
 };
diff --git a/client/src/components/MessageList.tsx b/client/src/components/MessageList.tsx
index 6c3c8bf..6e5e233 100644
--- a/client/src/components/MessageList.tsx
+++ b/client/src/components/MessageList.tsx
@@ -16,24 +16,9 @@
  * <https://www.gnu.org/licenses/>.
  */
 import { Stack } from '@mui/system';
-import dayjs, { Dayjs } from 'dayjs';
-import dayOfYear from 'dayjs/plugin/dayOfYear';
-import isBetween from 'dayjs/plugin/isBetween';
 import { Account, ConversationMember, Message } from 'jami-web-common';
-import { ReactNode, useMemo } from 'react';
 
-import {
-  MessageBubblesGroup,
-  MessageCall,
-  MessageDate,
-  MessageInitial,
-  MessageMember,
-  MessageMerge,
-  MessageTime,
-} from './Message';
-
-dayjs.extend(dayOfYear);
-dayjs.extend(isBetween);
+import { MessageRow } from './Message';
 
 interface MessageListProps {
   account: Account;
@@ -42,113 +27,31 @@
 }
 
 export default function MessageList({ account, members, messages }: MessageListProps) {
-  const messageComponents = useMemo(() => buildMessagesList(account, members, messages), [account, members, messages]);
-  return <Stack direction="column-reverse">{messageComponents}</Stack>;
-}
-
-const buildMessagesList = (account: Account, members: ConversationMember[], messages: Message[]) => {
-  if (messages.length === 0) {
-    return [];
-  }
-
-  const messageComponents: ReactNode[] = [];
-  let lastTime = dayjs.unix(Number(messages[0].timestamp));
-  let lastAuthor = messages[0].author;
-  let messagesGroup: Message[] = [];
-
-  const pushMessageBubblesGroup = () => {
-    if (messagesGroup.length === 0) {
-      return;
-    }
-    const props = { account, members, messages: messagesGroup };
-    messageComponents.push(<MessageBubblesGroup key={`group-${messagesGroup[0].id}`} {...props} />);
-    messagesGroup = [];
-  };
-
-  const pushMessageCall = (message: Message) => {
-    const props = { message };
-    messageComponents.push(<MessageCall key={`call-${message.id}`} {...props} />);
-  };
-
-  const pushMessageMember = (message: Message) => {
-    const props = { message };
-    messageComponents.push(<MessageMember key={`member-${message.id}`} {...props} />);
-  };
-
-  const pushMessageMerge = (message: Message) => {
-    const props = { message };
-    messageComponents.push(<MessageMerge key={`merge-${message.id}`} {...props} />);
-  };
-
-  const pushMessageTime = (message: Message, time: Dayjs, hasDateOnTop = false) => {
-    const props = { time, hasDateOnTop };
-    messageComponents.push(<MessageTime key={`time-${message.id}`} {...props} />);
-  };
-
-  const pushMessageDate = (message: Message, time: Dayjs) => {
-    const props = { time };
-    messageComponents.push(<MessageDate key={`date-${message.id}`} {...props} />);
-  };
-
-  const pushMessageInitial = (message: Message) => {
-    const props = { message };
-    messageComponents.push(<MessageInitial key={`initial-${message.id}`} {...props} />);
-  };
-
-  messages.forEach((message) => {
-    // most recent messages first
-    switch (message.type) {
-      case 'text/plain':
-      case 'application/data-transfer+json':
-        if (lastAuthor !== message.author) {
-          pushMessageBubblesGroup();
-        }
-        messagesGroup.push(message);
-        break;
-      case 'application/call-history+json':
-        pushMessageBubblesGroup();
-        pushMessageCall(message);
-        break;
-      case 'member':
-        pushMessageBubblesGroup();
-        pushMessageMember(message);
-        break;
-      case 'merge':
-        pushMessageBubblesGroup();
-        pushMessageMerge(message);
-        break;
-      case 'initial':
-      default:
-        break;
-    }
-
-    const time = dayjs.unix(Number(message.timestamp));
-    if (message.type === 'initial') {
-      pushMessageBubblesGroup();
-      pushMessageTime(message, time, true);
-      pushMessageDate(message, time);
-      pushMessageInitial(message);
-    } else {
-      if (
-        // If the date is different
-        lastTime?.year() !== time.year() ||
-        lastTime?.dayOfYear() !== time.dayOfYear()
-      ) {
-        pushMessageBubblesGroup();
-        pushMessageTime(message, time, true);
-        pushMessageDate(message, time);
-      } else if (
-        // If more than 5 minutes have passed since the last message
-        !lastTime.isBetween(time, time?.add(5, 'minute'))
-      ) {
-        pushMessageBubblesGroup();
-        pushMessageTime(message, time);
+  return (
+    <Stack direction="column-reverse">
+      {
+        // most recent messages first
+        messages.map((message, index) => {
+          const isAccountMessage = message.author === account.getUri();
+          let author;
+          if (isAccountMessage) {
+            author = account;
+          } else {
+            const member = members.find((member) => message.author === member.contact.getUri());
+            author = member?.contact;
+          }
+          if (!author) {
+            return null;
+          }
+          const props = {
+            messageIndex: index,
+            messages,
+            isAccountMessage,
+            author,
+          };
+          return <MessageRow key={message.id} {...props} />;
+        })
       }
-
-      lastTime = time;
-      lastAuthor = message.author;
-    }
-  });
-
-  return messageComponents;
-};
+    </Stack>
+  );
+}