Skip to content

Commit

Permalink
CRS-293 Max attachments (#577)
Browse files Browse the repository at this point in the history
* fix error if uploads complete after they've been removed in the interface

* limit max number of files

* add tests for multipleUploads and for maxNumberOfFiles

* use ChannelContext.maxNumberOfFiles to prevent uploading too many files with dropzone / file upload button

* upgrade react-file-utils

* add translations
  • Loading branch information
Tom Hutman authored Oct 19, 2020
1 parent 0b7f8f8 commit 8f25c61
Show file tree
Hide file tree
Showing 15 changed files with 169 additions and 62 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
"pretty-bytes": "^5.4.1",
"prop-types": "^15.7.2",
"react-fast-compare": "^3.2.0",
"react-file-utils": "0.3.17",
"react-file-utils": "0.4.0",
"react-images": "^1.1.7",
"react-is": "^16.13.1",
"react-markdown": "^4.3.1",
Expand Down
6 changes: 5 additions & 1 deletion src/components/Channel/Channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const Channel = ({ EmptyPlaceholder = null, ...props }) => {
return <ChannelInner {...props} channel={channel} key={channel.cid} />;
};

Channel.defaultProps = {
multipleUploads: true,
};

Channel.propTypes = {
/** Which channel to connect to, will initialize the channel if it's not initialized yet */
channel: PropTypes.instanceOf(StreamChannel),
Expand Down Expand Up @@ -97,7 +101,7 @@ Channel.propTypes = {
* @param {User} user Target [user object](https://getstream.io/chat/docs/#chat-doc-set-user) which is hovered
*/
onMentionsHover: PropTypes.func,
/** Weather to allow multiple attachment uploads */
/** Whether to allow multiple attachment uploads */
multipleUploads: PropTypes.bool,
/** List of accepted file types */
acceptedFiles: PropTypes.array,
Expand Down
18 changes: 8 additions & 10 deletions src/components/MessageInput/MessageInputFlat.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ const MessageInputFlat = (props) => {
<ImageDropzone
accept={channelContext.acceptedFiles}
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >= channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
maxNumberOfFiles={messageInput.maxFilesLeft}
handleFiles={messageInput.uploadNewFiles}
>
<div className="str-chat__input-flat-wrapper">
Expand Down Expand Up @@ -75,14 +73,14 @@ const MessageInputFlat = (props) => {
className="str-chat__fileupload-wrapper"
data-testid="fileinput"
>
<Tooltip>{t('Attach files')}</Tooltip>
<Tooltip>
{messageInput.maxFilesLeft
? t('Attach files')
: t("You've reached the maximum number of files")}
</Tooltip>
<FileUploadButton
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >=
channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
accepts={channelContext.acceptedFiles}
handleFiles={messageInput.uploadNewFiles}
>
Expand Down
18 changes: 8 additions & 10 deletions src/components/MessageInput/MessageInputLarge.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,8 @@ const MessageInputLarge = (props) => {
<ImageDropzone
accept={channelContext.acceptedFiles}
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >= channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
maxNumberOfFiles={messageInput.maxFilesLeft}
handleFiles={messageInput.uploadNewFiles}
>
<div className="str-chat__input">
Expand Down Expand Up @@ -102,14 +100,14 @@ const MessageInputLarge = (props) => {
className="str-chat__fileupload-wrapper"
data-testid="fileinput"
>
<Tooltip>{t('Attach files')}</Tooltip>
<Tooltip>
{messageInput.maxFilesLeft
? t('Attach files')
: t("You've reached the maximum number of files")}
</Tooltip>
<FileUploadButton
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >=
channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
accepts={channelContext.acceptedFiles}
handleFiles={messageInput.uploadNewFiles}
>
Expand Down
18 changes: 8 additions & 10 deletions src/components/MessageInput/MessageInputSmall.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ const MessageInputSmall = (props) => {
<ImageDropzone
accept={channelContext.acceptedFiles}
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >= channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
maxNumberOfFiles={messageInput.maxFilesLeft}
handleFiles={messageInput.uploadNewFiles}
>
<div
Expand Down Expand Up @@ -77,14 +75,14 @@ const MessageInputSmall = (props) => {
className="str-chat__fileupload-wrapper"
data-testid="fileinput"
>
<Tooltip>{t('Attach files')}</Tooltip>
<Tooltip>
{messageInput.maxFilesLeft
? t('Attach files')
: t("You've reached the maximum number of files")}
</Tooltip>
<FileUploadButton
multiple={channelContext.multipleUploads}
disabled={
channelContext.maxNumberOfFiles !== undefined &&
messageInput.numberOfUploads >=
channelContext.maxNumberOfFiles
}
disabled={messageInput.maxFilesLeft === 0}
accepts={channelContext.acceptedFiles}
handleFiles={messageInput.uploadNewFiles}
>
Expand Down
90 changes: 84 additions & 6 deletions src/components/MessageInput/__tests__/MessageInput.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React, { useContext, useEffect } from 'react';
import { cleanup, render, waitFor, fireEvent } from '@testing-library/react';
import {
cleanup,
render,
waitFor,
fireEvent,
act,
} from '@testing-library/react';
import '@testing-library/jest-dom';
import MessageInput from '../MessageInput';
import MessageInputLarge from '../MessageInputLarge';
Expand Down Expand Up @@ -43,8 +49,8 @@ const ActiveChannelSetter = ({ activeChannel }) => {
{ InputComponent: MessageInputSmall, name: 'MessageInputSmall' },
{ InputComponent: MessageInputFlat, name: 'MessageInputFlat' },
{ InputComponent: EditMessageForm, name: 'EditMessageForm' },
].forEach(({ InputComponent, name }) => {
const renderComponent = (props = {}) => {
].forEach(({ InputComponent, name: componentName }) => {
const renderComponent = (props = {}, channelProps = {}) => {
// MessageInput components rely on ChannelContext.
// ChannelContext is created by Channel component,
// Which relies on ChatContext, created by Chat component.
Expand All @@ -54,6 +60,7 @@ const ActiveChannelSetter = ({ activeChannel }) => {
<Channel
doSendMessageRequest={submitMock}
doUpdateMessageRequest={editMock}
{...channelProps}
>
<MessageInput Input={InputComponent} {...props} />
</Channel>
Expand All @@ -69,7 +76,7 @@ const ActiveChannelSetter = ({ activeChannel }) => {
return { submit, ...renderResult };
};

describe(`${name}`, () => {
describe(`${componentName}`, () => {
const inputPlaceholder = 'Type your message';
const username = 'username';
const userid = 'userid';
Expand All @@ -96,6 +103,7 @@ const ActiveChannelSetter = ({ activeChannel }) => {
fireEvent.drop(formElement, {
dataTransfer: {
files: [file],
types: ['Files'],
},
});
}
Expand All @@ -105,8 +113,8 @@ const ActiveChannelSetter = ({ activeChannel }) => {

const getImage = () =>
new File(['content'], filename, { type: 'image/png' });
const getFile = () =>
new File(['content'], filename, { type: 'text/plain' });
const getFile = (name = filename) =>
new File(['content'], name, { type: 'text/plain' });

const mockUploadApi = () =>
jest.fn().mockImplementation(() =>
Expand Down Expand Up @@ -326,6 +334,76 @@ const ActiveChannelSetter = ({ activeChannel }) => {
);
});

it('should not set multiple attribute on the file input if mutltipleUploads is false', async () => {
const { findByTestId } = renderComponent(
{},
{
multipleUploads: false,
},
);
const input = (await findByTestId('fileinput')).querySelector('input');
expect(input).not.toHaveAttribute('multiple');
});

it('should set multiple attribute on the file input if mutltipleUploads is true', async () => {
const { findByTestId } = renderComponent(
{},
{
multipleUploads: true,
},
);
const input = (await findByTestId('fileinput')).querySelector('input');
expect(input).toHaveAttribute('multiple');
});

const filename1 = '1.txt';
const filename2 = '2.txt';
it('should only allow dropping maxNumberOfFiles files into the dropzone', async () => {
const { findByPlaceholderText, queryByText } = renderComponent(
{
doFileUploadRequest: mockUploadApi(),
},
{
maxNumberOfFiles: 1,
},
);

const formElement = await findByPlaceholderText(inputPlaceholder);

const file = getFile(filename1);
dropFile(file, formElement);
await waitFor(() => expect(queryByText(filename1)).toBeInTheDocument());

const file2 = getFile(filename2);
act(() => dropFile(file2, formElement));
await waitFor(() =>
expect(queryByText(filename2)).not.toBeInTheDocument(),
);
});

it('should only allow uploading 1 file if multipleUploads is false', async () => {
const { findByPlaceholderText, queryByText } = renderComponent(
{
doFileUploadRequest: mockUploadApi(),
},
{
multipleUploads: false,
},
);

const formElement = await findByPlaceholderText(inputPlaceholder);

const file = getFile(filename1);
dropFile(file, formElement);
await waitFor(() => expect(queryByText(filename1)).toBeInTheDocument());

const file2 = getFile(filename2);
act(() => dropFile(file2, formElement));
await waitFor(() =>
expect(queryByText(filename2)).not.toBeInTheDocument(),
);
});

// TODO: Check if pasting plaintext is not prevented -> tricky because recreating exact event is hard
// TODO: Remove image/file -> difficult because there is no easy selector and components are in react-file-utils
});
Expand Down
27 changes: 25 additions & 2 deletions src/components/MessageInput/hooks/messageInput.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// @ts-check
import { useReducer, useEffect, useContext, useRef, useCallback } from 'react';
import {
useReducer,
useEffect,
useContext,
useRef,
useCallback,
useMemo,
} from 'react';
import Immutable from 'seamless-immutable';
import { logChatPromiseExecution } from 'stream-chat';
import {
Expand Down Expand Up @@ -33,6 +40,8 @@ const emptyFileUploads = {};
/** @type {{ [id: string]: import('types').ImageUpload }} */
const emptyImageUploads = {};

const apiMaxNumberOfFiles = 10;

/**
* Initializes the state. Empty if the message prop is falsy.
* @param {import("stream-chat").MessageResponse | undefined} message
Expand Down Expand Up @@ -134,6 +143,7 @@ function messageInputReducer(state, action) {
};
case 'setImageUpload': {
const imageAlreadyExists = state.imageUploads[action.id];
if (!imageAlreadyExists && !action.file) return state;
const imageOrder = imageAlreadyExists
? state.imageOrder
: state.imageOrder.concat(action.id);
Expand All @@ -152,6 +162,7 @@ function messageInputReducer(state, action) {
}
case 'setFileUpload': {
const fileAlreadyExists = state.fileUploads[action.id];
if (!fileAlreadyExists && !action.file) return state;
const fileOrder = fileAlreadyExists
? state.fileOrder
: state.fileOrder.concat(action.id);
Expand Down Expand Up @@ -560,7 +571,6 @@ export default function useMessageInputState(props) {
}
return;
}
if (!imageUploads[id]) return; // removed before done
dispatch({
type: 'setImageUpload',
id,
Expand Down Expand Up @@ -665,8 +675,21 @@ export default function useMessageInputState(props) {
[uploadNewFiles, insertText],
);

// Number of files that the user can still add. Should never be more than the amount allowed by the API.
// If multipleUploads is false, we only want to allow a single upload.
const maxFilesAllowed = useMemo(() => {
if (!channelContext.multipleUploads) return 1;
if (channelContext.maxNumberOfFiles === undefined) {
return apiMaxNumberOfFiles;
}
return channelContext.maxNumberOfFiles;
}, [channelContext.maxNumberOfFiles, channelContext.multipleUploads]);

const maxFilesLeft = maxFilesAllowed - numberOfUploads;

return {
...state,
maxFilesLeft,
// refs
textareaRef,
emojiPickerRef,
Expand Down
1 change: 1 addition & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Type your message",
"Unmute": "Unmute",
"You have no channels currently": "You have no channels currently",
"You've reached the maximum number of files": "You've reached the maximum number of files",
"live": "live",
"this content could not be displayed": "this content could not be displayed",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} and {{ lastUser }} are typing...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Saisissez votre message",
"Unmute": "Désactiver muet",
"You have no channels currently": "Vous n'avez actuellement aucun canal",
"You've reached the maximum number of files": "Vous avez atteint le nombre maximum de fichiers",
"live": "en direct",
"this content could not be displayed": "ce contenu n'a pu être affiché",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} et {{ lastUser }} sont en train d'écrire...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/hi.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "अपना मैसेज लिखे",
"Unmute": "अनम्यूट",
"You have no channels currently": "आपके पास कोई चैनल नहीं है",
"You've reached the maximum number of files": "आप अधिकतम फ़ाइलों तक पहुँच गए हैं",
"live": "लाइव",
"this content could not be displayed": "यह कॉन्टेंट लोड नहीं हो पाया",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} और {{ lastUser }} टाइप कर रहे हैं...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Scrivi il tuo messaggio",
"Unmute": "Riattiva le notifiche",
"You have no channels currently": "Al momento non sono presenti canali",
"You've reached the maximum number of files": "Hai raggiunto il numero massimo di file",
"live": "live",
"this content could not be displayed": "questo contenuto non puó essere mostrato",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} e {{ lastUser }} stanno scrivendo...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Type je bericht",
"Unmute": "Unmute",
"You have no channels currently": "Er zijn geen chats beschikbaar",
"You've reached the maximum number of files": "Je hebt het maximale aantal bestanden bereikt",
"live": "live",
"this content could not be displayed": "Deze inhoud kan niet weergegeven worden",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} en {{ lastUser }} zijn aan het typen ...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Ваше сообщение",
"Unmute": "Включить уведомления",
"You have no channels currently": "У вас нет каналов в данный момент",
"You've reached the maximum number of files": "Вы достигли максимального количества файлов",
"live": "В прямом эфире",
"this content could not be displayed": "Этот контент не может быть отображен в данный момент",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} и {{ lastUser }} пишут...",
Expand Down
1 change: 1 addition & 0 deletions src/i18n/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"Type your message": "Mesajınızı yazın",
"Unmute": "Sesini aç",
"You have no channels currently": "Henüz kanalınız yok",
"You've reached the maximum number of files": "Maksimum dosya sayısına ulaştınız",
"live": "canlı",
"this content could not be displayed": "bu içerik gösterilemiyor",
"{{ commaSeparatedUsers }} and {{ lastUser }} are typing...": "{{ commaSeparatedUsers }} ve {{ lastUser }} yazıyor...",
Expand Down
Loading

0 comments on commit 8f25c61

Please sign in to comment.