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