set styles for uploading files
Change-Id: I7d3f934ea4f31769d72d1f21c733ab0c16b325f6
diff --git a/client/package.json b/client/package.json
index 523d9f1..2bab75d 100644
--- a/client/package.json
+++ b/client/package.json
@@ -44,13 +44,16 @@
"check-password-strength": "^2.0.7",
"dayjs": "^1.11.5",
"emoji-picker-react": "^3.6.1",
+ "filesize": "^10.0.5",
"framer-motion": "^7.3.5",
"i18next": "^21.9.2",
"jami-web-common": "file:../common",
+ "mime": "^3.0.0",
"qrcode.react": "^3.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.5",
+ "react-dropzone": "^14.2.3",
"react-emoji-render": "^1.2.4",
"react-fetch-hook": "^1.9.5",
"react-i18next": "^11.18.6",
diff --git a/client/src/components/FilePreview.tsx b/client/src/components/FilePreview.tsx
new file mode 100644
index 0000000..68c3424
--- /dev/null
+++ b/client/src/components/FilePreview.tsx
@@ -0,0 +1,144 @@
+/*
+ * 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 { AttachFile } from '@mui/icons-material';
+import { IconButton, IconButtonProps, Stack, Typography } from '@mui/material';
+import * as mime from 'mime';
+import { useRef } from 'react';
+
+import { FileHandler } from '../utils/files';
+import { useDataSizeUnits } from '../utils/units';
+import { SaltireIcon } from './SvgIcon';
+
+interface FilePreviewIconProps {
+ fileHandler: FileHandler;
+ size: string;
+}
+
+const FilePreviewIcon = ({ fileHandler, size }: FilePreviewIconProps) => {
+ if (fileHandler.file.type.split('/')[0] === 'image') {
+ return (
+ <img
+ src={fileHandler.url}
+ alt={fileHandler.file.name}
+ style={{ height: size, width: size, objectFit: 'cover' }}
+ />
+ );
+ }
+
+ const paddedSize = parseInt(size) * 0.8 + 'px';
+ return <AttachFile sx={{ fontSize: paddedSize }} />;
+};
+
+interface FilePreviewInfosProps {
+ fileHandler: FileHandler;
+}
+
+const FilePreviewInfos = ({ fileHandler }: FilePreviewInfosProps) => {
+ const file = fileHandler.file;
+ const fileSize = useDataSizeUnits(file.size);
+ const fileType = mime.getExtension(file.type)?.toUpperCase() || '';
+ return (
+ <Stack overflow="hidden">
+ <Typography variant="body1" fontWeight="bold" overflow="hidden" textOverflow="ellipsis" whiteSpace="nowrap">
+ {file.name}
+ </Typography>
+ <Typography variant="body1">{`${fileType} ${fileSize}`}</Typography>
+ </Stack>
+ );
+};
+
+const RemoveButton = (props: IconButtonProps) => {
+ const removeButtonSize = '24px';
+ const paddingPart = 0.25;
+ return (
+ <IconButton
+ {...props}
+ aria-label="remove file"
+ disableRipple={true}
+ sx={{
+ position: 'absolute',
+ height: removeButtonSize,
+ width: removeButtonSize,
+ right: -parseInt(removeButtonSize) * paddingPart + 'px',
+ top: -parseInt(removeButtonSize) * paddingPart + 'px',
+ fontSize: parseInt(removeButtonSize) * paddingPart * 2 + 'px',
+ color: 'black',
+ backgroundColor: 'white',
+ borderRadius: '100%',
+ boxShadow: '3px 3px 7px #00000029',
+ '&:hover': {
+ background: (theme) => theme.palette.primary.light,
+ },
+ }}
+ >
+ <SaltireIcon fontSize="inherit" />
+ </IconButton>
+ );
+};
+
+interface FilePreviewDeletableProps {
+ fileHandler: FileHandler;
+ remove: () => void;
+ borderColor: string;
+}
+
+export const FilePreviewRemovable = ({ fileHandler, remove, borderColor }: FilePreviewDeletableProps) => {
+ const linkRef = useRef<HTMLAnchorElement>(null);
+ const iconSize = '57px';
+
+ return (
+ <Stack
+ direction="row"
+ sx={{
+ flex: '1 1 200px',
+ minWidth: '100px',
+ maxWidth: '300px',
+ cursor: 'pointer',
+ }}
+ onClick={() => linkRef.current?.click()}
+ >
+ <a ref={linkRef} href={fileHandler.url} download hidden />
+ <Stack
+ sx={{
+ position: 'relative',
+ height: iconSize,
+ width: iconSize,
+ minWidth: iconSize,
+ marginRight: '16px',
+ borderRadius: '5px',
+ borderWidth: '3px',
+ borderColor: borderColor,
+ borderStyle: 'solid',
+ justifyContent: 'center',
+ alignItems: 'center',
+ }}
+ >
+ <RemoveButton
+ onClick={(e) => {
+ // Prevent the parent's link to be triggered
+ e.preventDefault();
+ e.stopPropagation();
+ remove();
+ }}
+ />
+ <FilePreviewIcon fileHandler={fileHandler} size={iconSize} />
+ </Stack>
+ <FilePreviewInfos fileHandler={fileHandler} />
+ </Stack>
+ );
+};
diff --git a/client/src/components/SendMessageForm.tsx b/client/src/components/SendMessageForm.tsx
index f003aae..797d810 100644
--- a/client/src/components/SendMessageForm.tsx
+++ b/client/src/components/SendMessageForm.tsx
@@ -34,16 +34,17 @@
account: Account;
members: ConversationMember[];
onSend: (message: string) => void;
+ openFilePicker: () => void;
};
-export default function SendMessageForm(props: SendMessageFormProps) {
+export default function SendMessageForm({ account, members, onSend, openFilePicker }: SendMessageFormProps) {
const [currentMessage, setCurrentMessage] = useState('');
- const placeholder = usePlaceholder(props.account, props.members);
+ const placeholder = usePlaceholder(account, members);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (currentMessage) {
- props.onSend(currentMessage);
+ onSend(currentMessage);
setCurrentMessage('');
}
};
@@ -55,8 +56,16 @@
);
return (
- <Stack component="form" onSubmit={handleSubmit} direction="row" alignItems="center" spacing="20px" padding="16px">
- <UploadFileButton />
+ <Stack
+ component="form"
+ onSubmit={handleSubmit}
+ direction="row"
+ alignItems="center"
+ spacing="20px"
+ paddingX="16px"
+ paddingTop="16px"
+ >
+ <UploadFileButton onClick={openFilePicker} />
<RecordVoiceMessageButton />
<RecordVideoMessageButton />
<Stack flexGrow={1}>
diff --git a/client/src/pages/ChatInterface.tsx b/client/src/pages/ChatInterface.tsx
index 86f77da..4ad1830 100644
--- a/client/src/pages/ChatInterface.tsx
+++ b/client/src/pages/ChatInterface.tsx
@@ -15,15 +15,18 @@
* License along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*/
-import { Divider, Stack } from '@mui/material';
+import { Box, Divider, Stack } from '@mui/material';
import { Account, ConversationMember, Message } from 'jami-web-common';
import { useCallback, useContext, useEffect, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import { FilePreviewRemovable } from '../components/FilePreview';
import LoadingPage from '../components/Loading';
import MessageList from '../components/MessageList';
import SendMessageForm from '../components/SendMessageForm';
import { SocketContext } from '../contexts/Socket';
import { useMessagesQuery, useSendMessageMutation } from '../services/Conversation';
+import { FileHandler } from '../utils/files';
type ChatInterfaceProps = {
account: Account;
@@ -39,6 +42,34 @@
const messagesQuery = useMessagesQuery(account.getId(), conversationId);
const sendMessageMutation = useSendMessageMutation(account.getId(), 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);
@@ -73,7 +104,21 @@
}
return (
- <Stack flex={1} overflow="hidden">
+ <Stack flex={1} overflow="hidden" {...getRootProps()} paddingBottom="16px">
+ {isDragActive && (
+ // dark overlay when the user is dragging a file
+ <Box
+ sx={{
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ backgroundColor: 'black',
+ opacity: '30%',
+ zIndex: 100,
+ }}
+ />
+ )}
+ <input {...getInputProps()} />
<MessageList account={account} members={members} messages={messages} />
<Divider
sx={{
@@ -81,7 +126,37 @@
borderTop: '1px solid #E5E5E5',
}}
/>
- <SendMessageForm account={account} members={members} onSend={sendMessage} />
+ <SendMessageForm account={account} members={members} 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>
);
};
diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts
new file mode 100644
index 0000000..49c3171
--- /dev/null
+++ b/client/src/utils/files.ts
@@ -0,0 +1,35 @@
+/*
+ * 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/>.
+ */
+
+export class FileHandler {
+ private static lastId = 0;
+
+ private static getNewId() {
+ return ++this.lastId;
+ }
+
+ readonly id: number;
+ readonly file: File;
+ readonly url: string;
+
+ constructor(file: File) {
+ this.id = FileHandler.getNewId();
+ this.file = file;
+ this.url = URL.createObjectURL(file);
+ }
+}
diff --git a/client/src/utils/units.ts b/client/src/utils/units.ts
new file mode 100644
index 0000000..553b66c
--- /dev/null
+++ b/client/src/utils/units.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { filesize } from 'filesize';
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+
+// 'filesize.js' library requires us to define the symbols ourselves for localization:
+// https://github.com/avoidwork/filesize.js/issues/64
+// https://github.com/avoidwork/filesize.js/issues/96
+// Could not find a library doing it by itself
+const dataSizeSymbols: Record<string, Record<string, string>> = {
+ fr: { B: 'o', kB: 'ko', MB: 'Mo', GB: 'Go', TB: 'To', PB: 'Po', EB: 'Eo', ZB: 'Zo', YB: 'Yo' },
+ ru: { B: 'Б', kB: 'кБ', MB: 'МБ', GB: 'ГБ', TB: 'ТБ', PB: 'ПБ', EB: 'ЭБ', ZB: 'ЗБ', YB: 'ЙБ' },
+ default: { B: 'B', kB: 'kB', MB: 'MB', GB: 'GB', TB: 'TB', PB: 'PB', EB: 'EB', ZB: 'ZB', YB: 'YB' },
+};
+
+export const useDataSizeUnits = (nbBytes: number) => {
+ const { i18n } = useTranslation();
+ return useMemo(() => {
+ const options = {
+ symbols: dataSizeSymbols[i18n.language] || dataSizeSymbols['default'], // undefined is not supported
+ locale: i18n.language,
+ };
+ return filesize(nbBytes, options);
+ }, [i18n, nbBytes]);
+};