blob: 4ac80374aabcb5da061c2b242046f009021a35c9 [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';
idillona4b96ab2023-02-01 15:30:12 -050021import { useCallback, 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';
idillona4b96ab2023-02-01 15:30:12 -050031import { useWebSocketContext } 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 = () => {
idillona4b96ab2023-02-01 15:30:12 -050038 const webSocket = useWebSocketContext();
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(() => {
idillona4b96ab2023-02-01 15:30:12 -050093 const conversationMessageListener = (data: ConversationMessage) => {
94 console.log('newMessage');
95 setMessages((messages) => addMessage(messages, data.message));
96 };
simona5c54ef2022-11-18 05:26:06 -050097
idillona4b96ab2023-02-01 15:30:12 -050098 webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
Misha Krieger-Raynauld20cf1c82022-11-23 20:26:50 -050099
idillona4b96ab2023-02-01 15:30:12 -0500100 return () => {
101 webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener);
102 };
simona5c54ef2022-11-18 05:26:06 -0500103 }, [webSocket]);
idillon6847e252022-11-04 11:50:08 -0400104
105 if (isLoading) {
106 return <LoadingPage />;
107 } else if (error) {
108 return <div>Error loading {conversationId}</div>;
109 }
110
111 return (
idilloncab81d72022-11-08 12:20:00 -0500112 <Stack flex={1} overflow="hidden" {...getRootProps()} paddingBottom="16px">
idillonb0ec86a2022-12-06 17:54:55 -0500113 {isDragActive && <FileDragOverlay />}
idilloncab81d72022-11-08 12:20:00 -0500114 <input {...getInputProps()} />
idillon07d31cc2022-12-06 22:40:14 -0500115 <MessageList messages={messages} />
idillon3e378fd2022-12-23 11:48:12 -0500116 <ComposingMembersIndicator />
idillon6847e252022-11-04 11:50:08 -0400117 <Divider
118 sx={{
idillon3e378fd2022-12-23 11:48:12 -0500119 marginX: '16px',
idillon6847e252022-11-04 11:50:08 -0400120 borderTop: '1px solid #E5E5E5',
121 }}
122 />
idillon07d31cc2022-12-06 22:40:14 -0500123 <SendMessageForm onSend={sendMessage} openFilePicker={openFilePicker} />
idilloncab81d72022-11-08 12:20:00 -0500124 {fileHandlers.length > 0 && <FilePreviewsList fileHandlers={fileHandlers} removeFile={removeFile} />}
125 </Stack>
126 );
127};
128
129interface FilePreviewsListProps {
130 fileHandlers: FileHandler[];
131 removeFile: (fileId: string | number) => void;
132}
133
134const FilePreviewsList = ({ fileHandlers, removeFile }: FilePreviewsListProps) => {
135 return (
136 <Stack
137 direction="row"
138 flexWrap="wrap"
139 gap="16px"
140 overflow="auto"
141 maxHeight="30%"
142 paddingX="16px"
143 marginTop="12px" // spacing with the component on top
144 paddingTop="4px" // spacing so "RemoveButton" are not cut
145 >
146 {fileHandlers.map((fileHandler) => (
147 <FilePreviewRemovable
148 key={fileHandler.id}
149 remove={() => removeFile(fileHandler.id)}
150 fileHandler={fileHandler}
151 borderColor={'#005699' /* Should be same color as message bubble */}
152 />
153 ))}
idillon6847e252022-11-04 11:50:08 -0400154 </Stack>
155 );
156};
157
idillon3e378fd2022-12-23 11:48:12 -0500158export const ComposingMembersIndicator = () => {
159 const { t } = useTranslation();
160 const { composingMembers } = useConversationContext();
161
162 const text = useMemo(() => {
163 const options: TranslateEnumerationOptions<ConversationMember> = {
164 elementPartialKey: 'member',
165 getElementValue: (member) => member.getDisplayName(),
166 translaters: [
167 () => '',
168 (interpolations) => t('are_composing_1', interpolations),
169 (interpolations) => t('are_composing_2', interpolations),
170 (interpolations) => t('are_composing_3', interpolations),
171 (interpolations) => t('are_composing_more', interpolations),
172 ],
173 };
174
175 return translateEnumeration<ConversationMember>(composingMembers, options);
176 }, [composingMembers, t]);
177
178 return (
179 <Stack height="30px" padding="0 16px" justifyContent="center">
180 <Fade in={composingMembers.length !== 0}>
181 <Stack
182 alignItems="center"
183 direction="row"
184 spacing="8.5px"
185 sx={(theme: any) => ({
186 height: theme.typography.caption.lineHeight,
187 })}
188 >
189 <WaitingDots />
190 <Typography variant="caption">{text}</Typography>
191 </Stack>
192 </Fade>
193 </Stack>
194 );
195};
196
197const SingleDot = ({ delay }: { delay: number }) => (
198 <Box
199 width="8px"
200 height="8px"
201 borderRadius="100%"
202 sx={{ backgroundColor: '#000000' }}
203 component={motion.div}
204 animate={{ scale: [0.75, 1, 0.75] }}
205 transition={{
206 delay,
207 duration: 0.5,
208 repeatDelay: 1,
209 repeatType: 'loop',
210 repeat: Infinity,
211 ease: 'easeInOut',
212 }}
213 />
214);
215
216const WaitingDots = () => {
217 return (
218 <Stack direction="row" spacing="5px">
219 <SingleDot delay={0} />
220 <SingleDot delay={0.5} />
221 <SingleDot delay={1} />
222 </Stack>
223 );
224};
225
idillon6847e252022-11-04 11:50:08 -0400226const addMessage = (sortedMessages: Message[], message: Message) => {
227 if (sortedMessages.length === 0) {
228 return [message];
229 } else if (message.id === sortedMessages[sortedMessages.length - 1].linearizedParent) {
230 return [...sortedMessages, message];
231 } else if (message.linearizedParent === sortedMessages[0].id) {
232 return [message, ...sortedMessages];
233 } else {
234 console.error("Can't insert message " + message.id);
235 return sortedMessages;
236 }
237};
238
239const sortMessages = (messages: Message[]) => {
240 let sortedMessages: Message[] = [];
241 messages.forEach((message) => (sortedMessages = addMessage(sortedMessages, message)));
242 return sortedMessages;
243};
244
245export default ChatInterface;