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