blob: fa428ac575d018d1801169df212e3e591d67ba00 [file] [log] [blame]
idillon6847e252022-11-04 11:50:08 -04001/*
2 * Copyright (C) 2022 Savoir-faire Linux Inc.
3 *
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU Affero General Public License as
6 * published by the Free Software Foundation; either version 3 of the
7 * License, or (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU Affero General Public License for more details.
13 *
14 * You should have received a copy of the GNU Affero General Public
15 * License along with this program. If not, see
16 * <https://www.gnu.org/licenses/>.
17 */
idillon3e378fd2022-12-23 11:48:12 -050018import { Box, Divider, Fade, Stack, Typography } from '@mui/material';
19import { motion } from 'framer-motion';
simonf9d78f22022-11-25 15:47:15 -050020import { ConversationMessage, Message, WebSocketMessageType } from 'jami-web-common';
idillon3e378fd2022-12-23 11:48:12 -050021import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
idilloncab81d72022-11-08 12:20:00 -050022import { useDropzone } from 'react-dropzone';
idillon3e378fd2022-12-23 11:48:12 -050023import { useTranslation } from 'react-i18next';
idillon6847e252022-11-04 11:50:08 -040024
idilloncab81d72022-11-08 12:20:00 -050025import { FilePreviewRemovable } from '../components/FilePreview';
idillon6847e252022-11-04 11:50:08 -040026import LoadingPage from '../components/Loading';
27import MessageList from '../components/MessageList';
idillonb0ec86a2022-12-06 17:54:55 -050028import { FileDragOverlay } from '../components/Overlay';
idillon6847e252022-11-04 11:50:08 -040029import SendMessageForm from '../components/SendMessageForm';
simon09fe4822022-11-30 23:36:25 -050030import { useConversationContext } from '../contexts/ConversationProvider';
Issam E. Maghni0432cb72022-11-12 06:09:26 +000031import { WebSocketContext } from '../contexts/WebSocketProvider';
idillon3e378fd2022-12-23 11:48:12 -050032import { ConversationMember } from '../models/conversation-member';
Misha Krieger-Raynauld6bbdacf2022-11-29 21:45:40 -050033import { useMessagesQuery, useSendMessageMutation } from '../services/conversationQueries';
idilloncab81d72022-11-08 12:20:00 -050034import { FileHandler } from '../utils/files';
idillon3e378fd2022-12-23 11:48:12 -050035import { translateEnumeration, TranslateEnumerationOptions } from '../utils/translations';
idillon6847e252022-11-04 11:50:08 -040036
simonf9d78f22022-11-25 15:47:15 -050037const ChatInterface = () => {
Issam E. Maghni0432cb72022-11-12 06:09:26 +000038 const webSocket = useContext(WebSocketContext);
idillon07d31cc2022-12-06 22:40:14 -050039 const { conversationId } = useConversationContext();
idillon6847e252022-11-04 11:50:08 -040040 const [messages, setMessages] = useState<Message[]>([]);
41 const [isLoading, setIsLoading] = useState(true);
42 const [error, setError] = useState(false);
43
simon5da8ca62022-11-09 15:21:25 -050044 const messagesQuery = useMessagesQuery(conversationId);
45 const sendMessageMutation = useSendMessageMutation(conversationId);
idillon6847e252022-11-04 11:50:08 -040046
idilloncab81d72022-11-08 12:20:00 -050047 const [fileHandlers, setFileHandlers] = useState<FileHandler[]>([]);
48
49 const onFilesDrop = useCallback(
50 (acceptedFiles: File[]) => {
51 const newFileHandlers = acceptedFiles.map((file) => new FileHandler(file));
52 setFileHandlers((oldFileHandlers) => [...oldFileHandlers, ...newFileHandlers]);
53 },
54 [setFileHandlers]
55 );
56
57 const removeFile = useCallback(
58 (fileId: string | number) => {
59 setFileHandlers((fileHandlers) => fileHandlers.filter((fileHandler) => fileHandler.id !== fileId));
60 },
61 [setFileHandlers]
62 );
63
64 const {
65 getRootProps,
66 getInputProps,
67 open: openFilePicker,
68 isDragActive,
69 } = useDropzone({
70 onDrop: onFilesDrop,
71 noClick: true,
72 noKeyboard: true,
73 });
74
idillon6847e252022-11-04 11:50:08 -040075 useEffect(() => {
76 if (messagesQuery.isSuccess) {
77 const sortedMessages = sortMessages(messagesQuery.data);
78 setMessages(sortedMessages);
79 }
80 }, [messagesQuery.isSuccess, messagesQuery.data]);
81
82 useEffect(() => {
83 setIsLoading(messagesQuery.isLoading);
84 }, [messagesQuery.isLoading]);
85
86 useEffect(() => {
87 setError(messagesQuery.isError);
88 }, [messagesQuery.isError]);
89
90 const sendMessage = useCallback((message: string) => sendMessageMutation.mutate(message), [sendMessageMutation]);
91
92 useEffect(() => {
Issam E. Maghni0432cb72022-11-12 06:09:26 +000093 if (webSocket) {
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050094 const conversationMessageListener = (data: ConversationMessage) => {
idillon6847e252022-11-04 11:50:08 -040095 console.log('newMessage');
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050096 setMessages((messages) => addMessage(messages, data.message));
simona5c54ef2022-11-18 05:26:06 -050097 };
98
99 webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -0500100
simona5c54ef2022-11-18 05:26:06 -0500101 return () => {
102 webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
103 };
idillon6847e252022-11-04 11:50:08 -0400104 }
simona5c54ef2022-11-18 05:26:06 -0500105 }, [webSocket]);
idillon6847e252022-11-04 11:50:08 -0400106
107 if (isLoading) {
108 return <LoadingPage />;
109 } else if (error) {
110 return <div>Error loading {conversationId}</div>;
111 }
112
113 return (
idilloncab81d72022-11-08 12:20:00 -0500114 <Stack flex={1} overflow="hidden" {...getRootProps()} paddingBottom="16px">
idillonb0ec86a2022-12-06 17:54:55 -0500115 {isDragActive && <FileDragOverlay />}
idilloncab81d72022-11-08 12:20:00 -0500116 <input {...getInputProps()} />
idillon07d31cc2022-12-06 22:40:14 -0500117 <MessageList messages={messages} />
idillon3e378fd2022-12-23 11:48:12 -0500118 <ComposingMembersIndicator />
idillon6847e252022-11-04 11:50:08 -0400119 <Divider
120 sx={{
idillon3e378fd2022-12-23 11:48:12 -0500121 marginX: '16px',
idillon6847e252022-11-04 11:50:08 -0400122 borderTop: '1px solid #E5E5E5',
123 }}
124 />
idillon07d31cc2022-12-06 22:40:14 -0500125 <SendMessageForm onSend={sendMessage} openFilePicker={openFilePicker} />
idilloncab81d72022-11-08 12:20:00 -0500126 {fileHandlers.length > 0 && <FilePreviewsList fileHandlers={fileHandlers} removeFile={removeFile} />}
127 </Stack>
128 );
129};
130
131interface FilePreviewsListProps {
132 fileHandlers: FileHandler[];
133 removeFile: (fileId: string | number) => void;
134}
135
136const FilePreviewsList = ({ fileHandlers, removeFile }: FilePreviewsListProps) => {
137 return (
138 <Stack
139 direction="row"
140 flexWrap="wrap"
141 gap="16px"
142 overflow="auto"
143 maxHeight="30%"
144 paddingX="16px"
145 marginTop="12px" // spacing with the component on top
146 paddingTop="4px" // spacing so "RemoveButton" are not cut
147 >
148 {fileHandlers.map((fileHandler) => (
149 <FilePreviewRemovable
150 key={fileHandler.id}
151 remove={() => removeFile(fileHandler.id)}
152 fileHandler={fileHandler}
153 borderColor={'#005699' /* Should be same color as message bubble */}
154 />
155 ))}
idillon6847e252022-11-04 11:50:08 -0400156 </Stack>
157 );
158};
159
idillon3e378fd2022-12-23 11:48:12 -0500160export const ComposingMembersIndicator = () => {
161 const { t } = useTranslation();
162 const { composingMembers } = useConversationContext();
163
164 const text = useMemo(() => {
165 const options: TranslateEnumerationOptions<ConversationMember> = {
166 elementPartialKey: 'member',
167 getElementValue: (member) => member.getDisplayName(),
168 translaters: [
169 () => '',
170 (interpolations) => t('are_composing_1', interpolations),
171 (interpolations) => t('are_composing_2', interpolations),
172 (interpolations) => t('are_composing_3', interpolations),
173 (interpolations) => t('are_composing_more', interpolations),
174 ],
175 };
176
177 return translateEnumeration<ConversationMember>(composingMembers, options);
178 }, [composingMembers, t]);
179
180 return (
181 <Stack height="30px" padding="0 16px" justifyContent="center">
182 <Fade in={composingMembers.length !== 0}>
183 <Stack
184 alignItems="center"
185 direction="row"
186 spacing="8.5px"
187 sx={(theme: any) => ({
188 height: theme.typography.caption.lineHeight,
189 })}
190 >
191 <WaitingDots />
192 <Typography variant="caption">{text}</Typography>
193 </Stack>
194 </Fade>
195 </Stack>
196 );
197};
198
199const SingleDot = ({ delay }: { delay: number }) => (
200 <Box
201 width="8px"
202 height="8px"
203 borderRadius="100%"
204 sx={{ backgroundColor: '#000000' }}
205 component={motion.div}
206 animate={{ scale: [0.75, 1, 0.75] }}
207 transition={{
208 delay,
209 duration: 0.5,
210 repeatDelay: 1,
211 repeatType: 'loop',
212 repeat: Infinity,
213 ease: 'easeInOut',
214 }}
215 />
216);
217
218const WaitingDots = () => {
219 return (
220 <Stack direction="row" spacing="5px">
221 <SingleDot delay={0} />
222 <SingleDot delay={0.5} />
223 <SingleDot delay={1} />
224 </Stack>
225 );
226};
227
idillon6847e252022-11-04 11:50:08 -0400228const addMessage = (sortedMessages: Message[], message: Message) => {
229 if (sortedMessages.length === 0) {
230 return [message];
231 } else if (message.id === sortedMessages[sortedMessages.length - 1].linearizedParent) {
232 return [...sortedMessages, message];
233 } else if (message.linearizedParent === sortedMessages[0].id) {
234 return [message, ...sortedMessages];
235 } else {
236 console.error("Can't insert message " + message.id);
237 return sortedMessages;
238 }
239};
240
241const sortMessages = (messages: Message[]) => {
242 let sortedMessages: Message[] = [];
243 messages.forEach((message) => (sortedMessages = addMessage(sortedMessages, message)));
244 return sortedMessages;
245};
246
247export default ChatInterface;