Add composing notification

Change-Id: I2c052c4395a56ba6acf882cea3be4b82e2fde761
diff --git a/client/src/components/SendMessageForm.tsx b/client/src/components/SendMessageForm.tsx
index 0859d17..c0b9253 100644
--- a/client/src/components/SendMessageForm.tsx
+++ b/client/src/components/SendMessageForm.tsx
@@ -17,11 +17,13 @@
  */
 import { InputBase } from '@mui/material';
 import { Stack } from '@mui/system';
-import { ChangeEvent, FormEvent, useCallback, useMemo, useState } from 'react';
+import { WebSocketMessageType } from 'jami-web-common';
+import { ChangeEvent, FormEvent, useCallback, useContext, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 
 import { useAuthContext } from '../contexts/AuthProvider';
 import { useConversationContext } from '../contexts/ConversationProvider';
+import { WebSocketContext } from '../contexts/WebSocketProvider';
 import { ConversationMember } from '../models/conversation-member';
 import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
 import {
@@ -38,18 +40,46 @@
 };
 
 export default function SendMessageForm({ onSend, openFilePicker }: SendMessageFormProps) {
-  const { members } = useConversationContext();
+  const webSocket = useContext(WebSocketContext);
+  const { members, conversationId } = useConversationContext();
   const [currentMessage, setCurrentMessage] = useState('');
+  const composingNotificationTimeRef = useRef(0);
   const placeholder = usePlaceholder(members);
 
-  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
-    e.preventDefault();
-    if (currentMessage) {
-      onSend(currentMessage);
-      setCurrentMessage('');
+  const notifyComposing = useCallback(() => {
+    const currentTime = new Date().getTime();
+    // The daemon automatically turns off "isComposing" after 12 seconds
+    // We ensure it will stay on at least 4 seconds after the last typed character
+    if (currentTime - composingNotificationTimeRef.current > 8000) {
+      composingNotificationTimeRef.current = currentTime;
+      webSocket?.send(WebSocketMessageType.SetIsComposing, { conversationId, isWriting: true });
     }
-  };
-  const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => setCurrentMessage(event.target.value);
+  }, [webSocket, conversationId]);
+
+  const notifyStopcomposing = useCallback(() => {
+    composingNotificationTimeRef.current = 0;
+    webSocket?.send(WebSocketMessageType.SetIsComposing, { conversationId, isWriting: false });
+  }, [webSocket, conversationId]);
+
+  const handleSubmit = useCallback(
+    (e: FormEvent<HTMLFormElement>) => {
+      e.preventDefault();
+      if (currentMessage) {
+        onSend(currentMessage);
+        setCurrentMessage('');
+        notifyStopcomposing();
+      }
+    },
+    [currentMessage, onSend, notifyStopcomposing]
+  );
+
+  const handleInputChange = useCallback(
+    (event: ChangeEvent<HTMLInputElement>) => {
+      setCurrentMessage(event.target.value);
+      notifyComposing();
+    },
+    [notifyComposing]
+  );
 
   const onEmojiSelected = useCallback(
     (emoji: string) => setCurrentMessage((currentMessage) => currentMessage + emoji),
diff --git a/client/src/contexts/ConversationProvider.tsx b/client/src/contexts/ConversationProvider.tsx
index 9e805e3..3c24896 100644
--- a/client/src/contexts/ConversationProvider.tsx
+++ b/client/src/contexts/ConversationProvider.tsx
@@ -15,8 +15,8 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { ConversationInfos, ConversationView, WebSocketMessageType } from 'jami-web-common';
-import { useContext, useEffect, useMemo } from 'react';
+import { ComposingStatus, ConversationInfos, ConversationView, WebSocketMessageType } from 'jami-web-common';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 
 import LoadingPage from '../components/Loading';
 import { createOptionalContext } from '../hooks/createOptionalContext';
@@ -34,6 +34,7 @@
   conversationDisplayName: string;
   conversationInfos: ConversationInfos;
   members: ConversationMember[];
+  composingMembers: ConversationMember[];
 }
 
 const optionalConversationContext = createOptionalContext<IConversationContext>('ConversationContext');
@@ -46,6 +47,7 @@
   } = useUrlParams<ConversationRouteParams>();
   const { accountId, account } = useAuthContext();
   const webSocket = useContext(WebSocketContext);
+  const [composingMembers, setComposingMembers] = useState<ConversationMember[]>([]);
 
   const conversationInfosQuery = useConversationInfosQuery(conversationId!);
   const membersQuery = useMembersQuery(conversationId!);
@@ -65,6 +67,34 @@
 
   const conversationDisplayName = useConversationDisplayName(account, conversationInfos, members);
 
+  const onComposingStatusChanged = useCallback(
+    (data: ComposingStatus) => {
+      // FIXME: data.conversationId is an empty string. Don't know why. Should not be.
+      // Good enough for now, but will be a problem if the user has more than one conversation with the same contact.
+      // if (data.conversationId === conversationId)
+      {
+        setComposingMembers((composingMembers) => {
+          if (!data.isWriting) {
+            return composingMembers.filter(({ contact }) => contact.uri !== data.contactId);
+          }
+
+          const isAlreadyIncluded = composingMembers.find((member) => member.contact.uri === data.contactId);
+          if (isAlreadyIncluded) {
+            return composingMembers;
+          }
+
+          const member = members?.find((member) => member.contact.uri === data.contactId);
+          if (!member) {
+            return composingMembers;
+          }
+
+          return [...composingMembers, member];
+        });
+      }
+    },
+    [/*conversationId,*/ members]
+  );
+
   useEffect(() => {
     if (!conversationInfos || !conversationId || !webSocket) {
       return;
@@ -75,7 +105,10 @@
     };
 
     webSocket.send(WebSocketMessageType.ConversationView, conversationView);
-  }, [accountId, conversationInfos, conversationId, webSocket]);
+    webSocket.bind(WebSocketMessageType.OnComposingStatusChanged, onComposingStatusChanged);
+
+    return () => webSocket.unbind(WebSocketMessageType.OnComposingStatusChanged, onComposingStatusChanged);
+  }, [accountId, conversationInfos, conversationId, onComposingStatusChanged, webSocket]);
 
   const value = useMemo(() => {
     if (!conversationId || !conversationDisplayName || !conversationInfos || !members) {
@@ -87,8 +120,9 @@
       conversationDisplayName,
       conversationInfos,
       members,
+      composingMembers,
     };
-  }, [conversationId, conversationDisplayName, conversationInfos, members]);
+  }, [conversationId, conversationDisplayName, conversationInfos, members, composingMembers]);
 
   if (isLoading) {
     return <LoadingPage />;
diff --git a/client/src/locale/en/translation.json b/client/src/locale/en/translation.json
index cdae799..7985045 100644
--- a/client/src/locale/en/translation.json
+++ b/client/src/locale/en/translation.json
@@ -2,6 +2,10 @@
   "accept_call_audio": "Accept in audio",
   "accept_call_video": "Accept in video",
   "admin_creation_submit_button": "Create admin account",
+  "are_composing_1": "{{member0}} is writing",
+  "are_composing_2": "{{member0}} and {{member1}} are writing",
+  "are_composing_3": "{{member0}}, {{member1}} and {{member2}} are writing",
+  "are_composing_more": "{{member0}}, {{member1}}, {{member2}} +{{excess}} are writing",
   "calling": "Calling {{member0}}",
   "change_picture": "Change the picture",
   "connecting": "Connecting...",
diff --git a/client/src/locale/fr/translation.json b/client/src/locale/fr/translation.json
index d3fdd26..f4ba848 100644
--- a/client/src/locale/fr/translation.json
+++ b/client/src/locale/fr/translation.json
@@ -2,6 +2,10 @@
   "accept_call_audio": "Accepter en audio",
   "accept_call_video": "Accepter en vidéo",
   "admin_creation_submit_button": "Créer un compte admin",
+  "are_composing_1": "{{member0}} est en train d'écrire",
+  "are_composing_2": "{{member0}} et {{member1}} sont en train d'écrire",
+  "are_composing_3": "{{member0}}, {{member1}} et {{member2}} sont en train d'écrire",
+  "are_composing_more": "{{member0}}, {{member1}}, {{member2}} +{{excess}} sont en train d'écrire",
   "calling": "Appel vers {{member0}}",
   "change_picture": "Modifier l'image",
   "connecting": "Connexion en cours...",
diff --git a/client/src/pages/ChatInterface.tsx b/client/src/pages/ChatInterface.tsx
index 37e75e2..fa428ac 100644
--- a/client/src/pages/ChatInterface.tsx
+++ b/client/src/pages/ChatInterface.tsx
@@ -15,10 +15,12 @@
  * License along with this program.  If not, see
  * <https://www.gnu.org/licenses/>.
  */
-import { Divider, Stack } from '@mui/material';
+import { Box, Divider, Fade, Stack, Typography } from '@mui/material';
+import { motion } from 'framer-motion';
 import { ConversationMessage, Message, WebSocketMessageType } from 'jami-web-common';
-import { useCallback, useContext, useEffect, useState } from 'react';
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
 import { useDropzone } from 'react-dropzone';
+import { useTranslation } from 'react-i18next';
 
 import { FilePreviewRemovable } from '../components/FilePreview';
 import LoadingPage from '../components/Loading';
@@ -27,8 +29,10 @@
 import SendMessageForm from '../components/SendMessageForm';
 import { useConversationContext } from '../contexts/ConversationProvider';
 import { WebSocketContext } from '../contexts/WebSocketProvider';
+import { ConversationMember } from '../models/conversation-member';
 import { useMessagesQuery, useSendMessageMutation } from '../services/conversationQueries';
 import { FileHandler } from '../utils/files';
+import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
 
 const ChatInterface = () => {
   const webSocket = useContext(WebSocketContext);
@@ -111,9 +115,10 @@
       {isDragActive && <FileDragOverlay />}
       <input {...getInputProps()} />
       <MessageList messages={messages} />
+      <ComposingMembersIndicator />
       <Divider
         sx={{
-          margin: '30px 16px 0px 16px',
+          marginX: '16px',
           borderTop: '1px solid #E5E5E5',
         }}
       />
@@ -152,6 +157,74 @@
   );
 };
 
+export const ComposingMembersIndicator = () => {
+  const { t } = useTranslation();
+  const { composingMembers } = useConversationContext();
+
+  const text = useMemo(() => {
+    const options: TranslateEnumerationOptions<ConversationMember> = {
+      elementPartialKey: 'member',
+      getElementValue: (member) => member.getDisplayName(),
+      translaters: [
+        () => '',
+        (interpolations) => t('are_composing_1', interpolations),
+        (interpolations) => t('are_composing_2', interpolations),
+        (interpolations) => t('are_composing_3', interpolations),
+        (interpolations) => t('are_composing_more', interpolations),
+      ],
+    };
+
+    return translateEnumeration<ConversationMember>(composingMembers, options);
+  }, [composingMembers, t]);
+
+  return (
+    <Stack height="30px" padding="0 16px" justifyContent="center">
+      <Fade in={composingMembers.length !== 0}>
+        <Stack
+          alignItems="center"
+          direction="row"
+          spacing="8.5px"
+          sx={(theme: any) => ({
+            height: theme.typography.caption.lineHeight,
+          })}
+        >
+          <WaitingDots />
+          <Typography variant="caption">{text}</Typography>
+        </Stack>
+      </Fade>
+    </Stack>
+  );
+};
+
+const SingleDot = ({ delay }: { delay: number }) => (
+  <Box
+    width="8px"
+    height="8px"
+    borderRadius="100%"
+    sx={{ backgroundColor: '#000000' }}
+    component={motion.div}
+    animate={{ scale: [0.75, 1, 0.75] }}
+    transition={{
+      delay,
+      duration: 0.5,
+      repeatDelay: 1,
+      repeatType: 'loop',
+      repeat: Infinity,
+      ease: 'easeInOut',
+    }}
+  />
+);
+
+const WaitingDots = () => {
+  return (
+    <Stack direction="row" spacing="5px">
+      <SingleDot delay={0} />
+      <SingleDot delay={0.5} />
+      <SingleDot delay={1} />
+    </Stack>
+  );
+};
+
 const addMessage = (sortedMessages: Message[], message: Message) => {
   if (sortedMessages.length === 0) {
     return [message];
diff --git a/client/src/utils/translations.ts b/client/src/utils/translations.ts
index 4a559a5..81dfffa 100644
--- a/client/src/utils/translations.ts
+++ b/client/src/utils/translations.ts
@@ -40,9 +40,9 @@
     interpolations[elementKey] = options.getElementValue(list[i]);
   }
 
-  interpolations.excess = (max - quantity + 1).toString();
+  interpolations.excess = (quantity - max + 2).toString();
 
-  const translaterIndex = quantity <= max ? quantity : max;
+  const translaterIndex = quantity < max ? quantity : max - 1;
 
   return options.translaters[translaterIndex](interpolations);
 };