| /* |
| * 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 { Box, Divider, Fade, Stack, Typography } from '@mui/material'; |
| import { motion } from 'framer-motion'; |
| import { ConversationMessage, Message, WebSocketMessageType } from 'jami-web-common'; |
| import { useCallback, 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'; |
| import MessageList from '../components/MessageList'; |
| import { FileDragOverlay } from '../components/Overlay'; |
| import SendMessageForm from '../components/SendMessageForm'; |
| import { useConversationContext } from '../contexts/ConversationProvider'; |
| import { useWebSocketContext } 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 = useWebSocketContext(); |
| const { conversationId } = useConversationContext(); |
| const [messages, setMessages] = useState<Message[]>([]); |
| const [isLoading, setIsLoading] = useState(true); |
| const [error, setError] = useState(false); |
| |
| const messagesQuery = useMessagesQuery(conversationId); |
| const sendMessageMutation = useSendMessageMutation(conversationId); |
| |
| const [fileHandlers, setFileHandlers] = useState<FileHandler[]>([]); |
| |
| const onFilesDrop = useCallback( |
| (acceptedFiles: File[]) => { |
| const newFileHandlers = acceptedFiles.map((file) => new FileHandler(file)); |
| setFileHandlers((oldFileHandlers) => [...oldFileHandlers, ...newFileHandlers]); |
| }, |
| [setFileHandlers] |
| ); |
| |
| const removeFile = useCallback( |
| (fileId: string | number) => { |
| setFileHandlers((fileHandlers) => fileHandlers.filter((fileHandler) => fileHandler.id !== fileId)); |
| }, |
| [setFileHandlers] |
| ); |
| |
| const { |
| getRootProps, |
| getInputProps, |
| open: openFilePicker, |
| isDragActive, |
| } = useDropzone({ |
| onDrop: onFilesDrop, |
| noClick: true, |
| noKeyboard: true, |
| }); |
| |
| useEffect(() => { |
| if (messagesQuery.isSuccess) { |
| const sortedMessages = sortMessages(messagesQuery.data); |
| setMessages(sortedMessages); |
| } |
| }, [messagesQuery.isSuccess, messagesQuery.data]); |
| |
| useEffect(() => { |
| setIsLoading(messagesQuery.isLoading); |
| }, [messagesQuery.isLoading]); |
| |
| useEffect(() => { |
| setError(messagesQuery.isError); |
| }, [messagesQuery.isError]); |
| |
| const sendMessage = useCallback((message: string) => sendMessageMutation.mutate(message), [sendMessageMutation]); |
| |
| useEffect(() => { |
| const conversationMessageListener = (data: ConversationMessage) => { |
| console.log('newMessage'); |
| setMessages((messages) => addMessage(messages, data.message)); |
| }; |
| |
| webSocket.bind(WebSocketMessageType.ConversationMessage, conversationMessageListener); |
| |
| return () => { |
| webSocket.unbind(WebSocketMessageType.ConversationMessage, conversationMessageListener); |
| }; |
| }, [webSocket]); |
| |
| if (isLoading) { |
| return <LoadingPage />; |
| } else if (error) { |
| return <div>Error loading {conversationId}</div>; |
| } |
| |
| return ( |
| <Stack flex={1} overflow="hidden" {...getRootProps()} paddingBottom="16px"> |
| {isDragActive && <FileDragOverlay />} |
| <input {...getInputProps()} /> |
| <MessageList messages={messages} /> |
| <ComposingMembersIndicator /> |
| <Divider |
| sx={{ |
| marginX: '16px', |
| borderTop: '1px solid #E5E5E5', |
| }} |
| /> |
| <SendMessageForm onSend={sendMessage} openFilePicker={openFilePicker} /> |
| {fileHandlers.length > 0 && <FilePreviewsList fileHandlers={fileHandlers} removeFile={removeFile} />} |
| </Stack> |
| ); |
| }; |
| |
| interface FilePreviewsListProps { |
| fileHandlers: FileHandler[]; |
| removeFile: (fileId: string | number) => void; |
| } |
| |
| const FilePreviewsList = ({ fileHandlers, removeFile }: FilePreviewsListProps) => { |
| return ( |
| <Stack |
| direction="row" |
| flexWrap="wrap" |
| gap="16px" |
| overflow="auto" |
| maxHeight="30%" |
| paddingX="16px" |
| marginTop="12px" // spacing with the component on top |
| paddingTop="4px" // spacing so "RemoveButton" are not cut |
| > |
| {fileHandlers.map((fileHandler) => ( |
| <FilePreviewRemovable |
| key={fileHandler.id} |
| remove={() => removeFile(fileHandler.id)} |
| fileHandler={fileHandler} |
| borderColor={'#005699' /* Should be same color as message bubble */} |
| /> |
| ))} |
| </Stack> |
| ); |
| }; |
| |
| 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]; |
| } else if (message.id === sortedMessages[sortedMessages.length - 1].linearizedParent) { |
| return [...sortedMessages, message]; |
| } else if (message.linearizedParent === sortedMessages[0].id) { |
| return [message, ...sortedMessages]; |
| } else { |
| console.error("Can't insert message " + message.id); |
| return sortedMessages; |
| } |
| }; |
| |
| const sortMessages = (messages: Message[]) => { |
| let sortedMessages: Message[] = []; |
| messages.forEach((message) => (sortedMessages = addMessage(sortedMessages, message))); |
| return sortedMessages; |
| }; |
| |
| export default ChatInterface; |