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);
 };
diff --git a/common/src/enums/websocket-message-type.ts b/common/src/enums/websocket-message-type.ts
index 38d2042..37036b9 100644
--- a/common/src/enums/websocket-message-type.ts
+++ b/common/src/enums/websocket-message-type.ts
@@ -18,6 +18,8 @@
 export enum WebSocketMessageType {
   ConversationMessage = 'conversation-message',
   ConversationView = 'conversation-view',
+  OnComposingStatusChanged = 'on-composing-status-changed', // Sent by server to indicate who is composing a message or not
+  SetIsComposing = 'set-is-composing', // Sent by user to indicate whether they are composing a message or not
   CallBegin = 'call-begin',
   CallAccept = 'call-accept',
   CallEnd = 'call-end',
diff --git a/common/src/interfaces/websocket-interfaces.ts b/common/src/interfaces/websocket-interfaces.ts
index 109adce..f680e16 100644
--- a/common/src/interfaces/websocket-interfaces.ts
+++ b/common/src/interfaces/websocket-interfaces.ts
@@ -30,6 +30,12 @@
   conversationId: string;
 }
 
+export interface ComposingStatus {
+  contactId?: string; // optional (ignored) when is about the user sending it
+  conversationId: string;
+  isWriting: boolean;
+}
+
 export interface CallAction extends ContactMessage {
   conversationId: string;
 }
diff --git a/common/src/interfaces/websocket-message.ts b/common/src/interfaces/websocket-message.ts
index c42fc44..af3078c 100644
--- a/common/src/interfaces/websocket-message.ts
+++ b/common/src/interfaces/websocket-message.ts
@@ -19,6 +19,7 @@
 import {
   CallAction,
   CallBegin,
+  ComposingStatus,
   ConversationMessage,
   ConversationView,
   WebRtcIceCandidate,
@@ -28,6 +29,8 @@
 export interface WebSocketMessageTable {
   [WebSocketMessageType.ConversationMessage]: ConversationMessage;
   [WebSocketMessageType.ConversationView]: ConversationView;
+  [WebSocketMessageType.OnComposingStatusChanged]: ComposingStatus;
+  [WebSocketMessageType.SetIsComposing]: ComposingStatus;
   [WebSocketMessageType.CallBegin]: CallBegin;
   [WebSocketMessageType.CallAccept]: CallAction;
   [WebSocketMessageType.CallEnd]: CallAction;
diff --git a/server/src/app.ts b/server/src/app.ts
index ff94a59..0e1b055 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -32,6 +32,7 @@
 import { linkPreviewRouter } from './routers/link-preview-router.js';
 import { nameserverRouter } from './routers/nameserver-router.js';
 import { setupRouter } from './routers/setup-router.js';
+import { bindChatCallbacks } from './websocket/chat-handler.js';
 import { bindWebRtcCallbacks } from './websocket/webrtc-handler.js';
 
 @Service()
@@ -59,6 +60,7 @@
     this.app.use('/ns', nameserverRouter);
 
     // Setup WebSocket callbacks
+    bindChatCallbacks();
     bindWebRtcCallbacks();
 
     // Setup 404 error handling
diff --git a/server/src/jamid/jami-signal-interfaces.ts b/server/src/jamid/jami-signal-interfaces.ts
index 851239b..188dfaf 100644
--- a/server/src/jamid/jami-signal-interfaces.ts
+++ b/server/src/jamid/jami-signal-interfaces.ts
@@ -133,3 +133,10 @@
   conversationId: string;
   message: Message;
 }
+
+export interface ComposingStatusChanged {
+  accountId: string;
+  conversationId: string;
+  from: string;
+  status: number;
+}
diff --git a/server/src/jamid/jami-signal.ts b/server/src/jamid/jami-signal.ts
index 6a9b15b..15bef48 100644
--- a/server/src/jamid/jami-signal.ts
+++ b/server/src/jamid/jami-signal.ts
@@ -25,6 +25,7 @@
   AccountsChanged = 'AccountsChanged',
   AccountDetailsChanged = 'AccountDetailsChanged',
   RegistrationStateChanged = 'RegistrationStateChanged',
+  ComposingStatusChanged = 'ComposingStatusChanged',
   IncomingTrustRequest = 'IncomingTrustRequest',
   ContactAdded = 'ContactAdded',
   ContactRemoved = 'ContactRemoved',
diff --git a/server/src/jamid/jami-swig.ts b/server/src/jamid/jami-swig.ts
index cd6a95c..07fa2dd 100644
--- a/server/src/jamid/jami-swig.ts
+++ b/server/src/jamid/jami-swig.ts
@@ -123,6 +123,7 @@
 
   sendMessage(accountId: string, conversationId: string, message: string, replyTo: string, flag: number): void;
   loadConversationMessages(accountId: string, conversationId: string, fromMessage: string, n: number): number;
+  setIsComposing(accountId: string, conversationId: string, isWriting: boolean): void;
 
   getCallList(accountId: string): StringVect;
   getCallDetails(accountId: string, callId: string): StringMap;
diff --git a/server/src/jamid/jamid.ts b/server/src/jamid/jamid.ts
index a868afe..ab4d8a8 100644
--- a/server/src/jamid/jamid.ts
+++ b/server/src/jamid/jamid.ts
@@ -19,6 +19,7 @@
 
 import {
   AccountDetails,
+  ComposingStatus,
   ContactDetails,
   ConversationInfos,
   ConversationMessage,
@@ -41,6 +42,7 @@
 import {
   AccountDetailsChanged,
   AccountMessageStatusChanged,
+  ComposingStatusChanged,
   ContactAdded,
   ContactRemoved,
   ConversationLoaded,
@@ -167,6 +169,10 @@
     handlers.MessageReceived = (accountId: string, conversationId: string, message: Message) =>
       onMessageReceived.next({ accountId, conversationId, message });
 
+    const onComposingStatusChanged = new Subject<ComposingStatusChanged>();
+    handlers.ComposingStatusChanged = (accountId: string, conversationId: string, from: string, status: number) =>
+      onComposingStatusChanged.next({ accountId, conversationId, from, status });
+
     // Expose all signals in an events object to allow other handlers to subscribe after jamiSwig.init()
     this.events = {
       onAccountsChanged: onAccountsChanged.asObservable(),
@@ -186,6 +192,7 @@
       onConversationLoaded: onConversationLoaded.asObservable(),
       onConversationMemberEvent: onConversationMemberEvent.asObservable(),
       onMessageReceived: onMessageReceived.asObservable(),
+      onComposingStatusChanged: onComposingStatusChanged.asObservable(),
     };
 
     this.setupSignalHandlers();
@@ -378,6 +385,10 @@
     this.jamiSwig.sendMessage(accountId, conversationId, message, replyTo ?? '', flag ?? 0);
   }
 
+  setIsComposing(accountId: string, conversationId: string, isWriting: boolean) {
+    this.jamiSwig.setIsComposing(accountId, conversationId, isWriting);
+  }
+
   getCallIds(accountId: string): string[] {
     return stringVectToArray(this.jamiSwig.getCallList(accountId));
   }
@@ -503,5 +514,16 @@
       };
       this.webSocketServer.send(signal.accountId, WebSocketMessageType.ConversationMessage, data);
     });
+
+    this.events.onComposingStatusChanged.subscribe((signal) => {
+      log.debug('Received ComposingStatusChanged:', JSON.stringify(signal));
+
+      const data: ComposingStatus = {
+        contactId: signal.from,
+        conversationId: signal.conversationId,
+        isWriting: signal.status === 1,
+      };
+      this.webSocketServer.send(signal.accountId, WebSocketMessageType.OnComposingStatusChanged, data);
+    });
   }
 }
diff --git a/server/src/websocket/chat-handler.ts b/server/src/websocket/chat-handler.ts
new file mode 100644
index 0000000..161203b
--- /dev/null
+++ b/server/src/websocket/chat-handler.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 { ComposingStatus, WebSocketMessageType } from 'jami-web-common';
+import log from 'loglevel';
+import { Container } from 'typedi';
+
+import { Jamid } from '../jamid/jamid.js';
+import { WebSocketServer } from './websocket-server.js';
+
+const jamid = Container.get(Jamid);
+const webSocketServer = Container.get(WebSocketServer);
+
+export function bindChatCallbacks() {
+  webSocketServer.bind(
+    WebSocketMessageType.SetIsComposing,
+    (accountId, { contactId, conversationId, isWriting }: ComposingStatus) => {
+      if (contactId !== undefined) {
+        log.warn('SetIsComposing expects contactId to be undefined');
+        return;
+      }
+
+      jamid.setIsComposing(accountId, conversationId, isWriting);
+    }
+  );
+}