Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CRS-293 Max attachments #577

Merged
merged 6 commits into from
Oct 19, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -35,6 +35,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 @@ -96,7 +100,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 */
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤣

/** 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