Skip to content
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,12 @@
"start": "concurrently npm:server npm:dev",
"dev": "react-scripts start",
"build": "node ./scripts/build.js",
"test": "cross-env TZ=utc jest",
"test": "cross-env TZ=utc jest --silent",
"eject": "react-scripts eject",
"lint": "eslint src server",
"server": "ts-node -T -P server/tsconfig.json server/index.ts",
"typescript:server": "tsc --noEmit -p server/",
"test:ci": "cross-env TZ=utc jest --ci --runInBand --reporters=default --reporters=jest-junit --coverage",
"test:ci": "cross-env TZ=utc jest --ci --runInBand --reporters=default --reporters=jest-junit --coverage --silent",
"cypress:open": "cypress open",
"cypress:run": "cypress run --browser chrome",
"cypress:ci": "cross-env CYPRESS_baseUrl=http://localhost:8081 start-server-and-test server http://localhost:8081 cypress:run",
Expand Down
80 changes: 79 additions & 1 deletion src/components/ChatWindow/ChatInput/ChatInput.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import useMediaQuery from '@material-ui/core/useMediaQuery';

import ChatInput from '../ChatInput/ChatInput';
import SendMessageIcon from '../../../icons/SendMessageIcon';
import { CircularProgress } from '@material-ui/core';
import FileAttachmentIcon from '../../../icons/FileAttachmentIcon';
import Snackbar from '../../Snackbar/Snackbar';

jest.mock('@material-ui/core/useMediaQuery');

const mockUseMediaQuery = useMediaQuery as jest.Mock<boolean>;
const mockHandleSendMessage = jest.fn();
const mockHandleSendMessage = jest.fn<any, (string | FormData)[]>(() => Promise.resolve());

describe('the ChatInput component', () => {
beforeAll(() => {
Expand Down Expand Up @@ -79,4 +82,79 @@ describe('the ChatInput component', () => {
wrapper.find(TextareaAutosize).simulate('keypress', { key: 'Enter', shiftKey: true });
expect(mockHandleSendMessage).not.toHaveBeenCalled();
});

it('should send a media message when a user selects a file', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find('input[type="file"]').simulate('change', { target: { files: ['mockFile'] } });
var formData = mockHandleSendMessage.mock.calls[0][0] as FormData;
expect(formData).toEqual(expect.any(FormData));
expect(formData.get('userfile')).toBe('mockFile');
});

it('should not send a media message when the "change" event is fired with no files', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find('input[type="file"]').simulate('change', { target: { files: [] } });
expect(mockHandleSendMessage).not.toHaveBeenCalled();
});

it('should disable the file input button and display a loading spinner while sending a file', done => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);

expect(wrapper.find(CircularProgress).exists()).toBe(false);
expect(
wrapper
.find(FileAttachmentIcon)
.parent()
.prop('disabled')
).toBe(false);

wrapper.find('input[type="file"]').simulate('change', { target: { files: ['mockFile'] } });

expect(wrapper.find(CircularProgress).exists()).toBe(true);
expect(
wrapper
.find(FileAttachmentIcon)
.parent()
.prop('disabled')
).toBe(true);

setImmediate(() => {
expect(wrapper.find(CircularProgress).exists()).toBe(false);
expect(
wrapper
.find(FileAttachmentIcon)
.parent()
.prop('disabled')
).toBe(false);
done();
});
});

it('should display an error when there is a problem sending a file', done => {
mockHandleSendMessage.mockImplementationOnce(() => Promise.reject({}));
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);

expect(wrapper.find(Snackbar).prop('open')).toBe(false);
wrapper.find('input[type="file"]').simulate('change', { target: { files: ['mockFile'] } });

setImmediate(() => {
expect(wrapper.find(Snackbar).prop('open')).toBe(true);
expect(wrapper.find(Snackbar).prop('message')).toBe('There was a problem uploading the file. Please try again.');
done();
});
});

it('should display a "file is too large" error when there is a 413 error code', done => {
mockHandleSendMessage.mockImplementationOnce(() => Promise.reject({ code: 413 }));
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);

expect(wrapper.find(Snackbar).prop('open')).toBe(false);
wrapper.find('input[type="file"]').simulate('change', { target: { files: ['mockFile'] } });

setImmediate(() => {
expect(wrapper.find(Snackbar).prop('open')).toBe(true);
expect(wrapper.find(Snackbar).prop('message')).toBe('File size is too large. Maximum file size is 150MB.');
done();
});
});
});
113 changes: 94 additions & 19 deletions src/components/ChatWindow/ChatInput/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import React, { useState } from 'react';
import React, { useRef, useState } from 'react';
import { Button, CircularProgress, Grid, makeStyles, Theme, useMediaQuery } from '@material-ui/core';
import { Conversation } from '@twilio/conversations/lib/conversation';
import FileAttachmentIcon from '../../../icons/FileAttachmentIcon';
import SendMessageIcon from '../../../icons/SendMessageIcon';
import Snackbar from '../../Snackbar/Snackbar';
import TextareaAutosize from '@material-ui/core/TextareaAutosize';
import { makeStyles, Theme, useMediaQuery } from '@material-ui/core';
import { Conversation } from '@twilio/conversations/lib/conversation';

const useStyles = makeStyles({
chatInputContainer: {
Expand All @@ -19,34 +21,48 @@ const useStyles = makeStyles({
fontSize: '14px',
fontFamily: 'Inter',
},
sendButton: {
height: '40px',
width: '40px',
border: '0',
borderRadius: '4px',
float: 'right',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '1em',
background: '#0263E0',
cursor: 'pointer',
button: {
padding: '0.56em',
minWidth: 'auto',
'&:disabled': {
background: 'none',
cursor: 'default',
'& path': {
fill: '#d8d8d8',
},
},
},
buttonContainer: {
margin: '1em 0 0 1em',
display: 'flex',
},
fileButtonContainer: {
position: 'relative',
marginRight: '1em',
},
fileButtonLoadingSpinner: {
position: 'absolute',
top: '50%',
left: '50%',
marginTop: -12,
marginLeft: -12,
},
});

interface ChatInputProps {
conversation: Conversation;
}

const ALLOWED_FILE_TYPES =
'audio/*, image/*, text/*, video/*, application/msword, application/vnd.openxmlformats-officedocument.wordprocessingml.document .xslx, .ppt, .pdf, .key, .svg, .csv';

export default function ChatInput({ conversation }: ChatInputProps) {
const classes = useStyles();
const [messageBody, setMessageBody] = useState('');
const [isSendingFile, setIsSendingFile] = useState(false);
const [fileSendError, setFileSendError] = useState<string | null>(null);
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'));
const isValidMessage = /\S/.test(messageBody);
const fileInputRef = useRef<HTMLInputElement>(null);

const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageBody(event.target.value);
Expand All @@ -67,8 +83,39 @@ export default function ChatInput({ conversation }: ChatInputProps) {
}
};

const handleSendFile = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
var formData = new FormData();
formData.append('userfile', file);
setIsSendingFile(true);
setFileSendError(null);
conversation
.sendMessage(formData)
.catch(e => {
if (e.code === 413) {
setFileSendError('File size is too large. Maximum file size is 150MB.');
} else {
setFileSendError('There was a problem uploading the file. Please try again.');
}
console.log('Problem sending file: ', e);
})
.finally(() => {
setIsSendingFile(false);
});
}
};

return (
<div className={classes.chatInputContainer}>
<Snackbar
open={Boolean(fileSendError)}
headline="Error"
message={fileSendError || ''}
variant="error"
handleClose={() => setFileSendError(null)}
/>

<TextareaAutosize
rowsMin={1}
rowsMax={3}
Expand All @@ -81,9 +128,37 @@ export default function ChatInput({ conversation }: ChatInputProps) {
value={messageBody}
/>

<button className={classes.sendButton} onClick={() => handleSendMessage(messageBody)} disabled={!isValidMessage}>
<SendMessageIcon />
</button>
<Grid container alignItems="flex-end" justify="flex-end" wrap="nowrap">
{/* Since the file input element is invisible, we can hardcode an empty string as its value.
This allows users to upload the same file multiple times. */}
<input
ref={fileInputRef}
type="file"
style={{ display: 'none' }}
onChange={handleSendFile}
value={''}
accept={ALLOWED_FILE_TYPES}
/>
<div className={classes.buttonContainer}>
<div className={classes.fileButtonContainer}>
<Button className={classes.button} onClick={() => fileInputRef.current?.click()} disabled={isSendingFile}>
<FileAttachmentIcon />
</Button>

{isSendingFile && <CircularProgress size={24} className={classes.fileButtonLoadingSpinner} />}
</div>

<Button
className={classes.button}
onClick={() => handleSendMessage(messageBody)}
color="primary"
variant="contained"
disabled={!isValidMessage}
>
<SendMessageIcon />
</Button>
</div>
</Grid>
</div>
);
}
10 changes: 6 additions & 4 deletions src/components/ChatWindow/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React from 'react';
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import ChatWindowHeader from './ChatWindowHeader/ChatWindowHeader';
import ChatInput from './ChatInput/ChatInput';
import clsx from 'clsx';
import MessageList from './MessageList/MessageList';
import useChatContext from '../../hooks/useChatContext/useChatContext';

Expand All @@ -12,6 +13,7 @@ const useStyles = makeStyles((theme: Theme) =>
zIndex: 100,
display: 'flex',
flexDirection: 'column',
borderLeft: '1px solid #E4E7E9',
[theme.breakpoints.down('sm')]: {
position: 'fixed',
top: 0,
Expand All @@ -20,18 +22,18 @@ const useStyles = makeStyles((theme: Theme) =>
right: 0,
},
},
hide: {
display: 'none',
},
})
);

export default function ChatWindow() {
const classes = useStyles();
const { isChatWindowOpen, messages, conversation } = useChatContext();

//if chat window is not open, don't render this component
if (!isChatWindowOpen) return null;

return (
<aside className={classes.chatWindowContainer}>
<aside className={clsx(classes.chatWindowContainer, { [classes.hide]: !isChatWindowOpen })}>
<ChatWindowHeader />
<MessageList messages={messages} />
<ChatInput conversation={conversation!} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const useStyles = makeStyles(() =>
container: {
height: '56px',
background: '#F4F4F6',
boxShadow: 'inset 0 -0.1em 0 #E4E7E9',
borderBottom: '1px solid #E4E7E9',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import React from 'react';
import { Media } from '@twilio/conversations/lib/media';
import MediaMessage, { formatFileSize } from './MediaMessage';
import { shallow } from 'enzyme';

jest.mock('@material-ui/core/styles/makeStyles', () => () => () => ({}));

describe('the formatFileSize function', () => {
[
{ bytes: 789, result: '789 bytes' },
{ bytes: 1000, result: '0.98 KB' },
{ bytes: 1234, result: '1.21 KB' },
{ bytes: 67384, result: '65.8 KB' },
{ bytes: 567123, result: '553.83 KB' },
{ bytes: 1000000, result: '976.56 KB' },
{ bytes: 1647987, result: '1.57 MB' },
{ bytes: 23789647, result: '22.69 MB' },
{ bytes: 798234605, result: '761.26 MB' },
{ bytes: 2458769876, result: '2.29 GB' },
].forEach(testCase => {
it(`should format ${testCase.bytes} to "${testCase.result}"`, () => {
expect(formatFileSize(testCase.bytes)).toBe(testCase.result);
});
});
});

describe('the MediaMessage component', () => {
it('should get the file URL and load it in a new tab when clicked', done => {
const mockMedia = {
filename: 'foo.txt',
size: 123,
getContentTemporaryUrl: () => Promise.resolve('http://twilio.com/foo.txt'),
} as Media;

const mockAnchorElement = document.createElement('a');
mockAnchorElement.click = jest.fn();
document.createElement = jest.fn(() => mockAnchorElement);

const wrapper = shallow(<MediaMessage media={mockMedia} />);
wrapper.simulate('click');

setTimeout(() => {
expect(mockAnchorElement.href).toBe('http://twilio.com/foo.txt');
expect(mockAnchorElement.download).toBe('foo.txt');
expect(mockAnchorElement.target).toBe('_blank');
expect(mockAnchorElement.rel).toBe('noopener');
expect(mockAnchorElement.click).toHaveBeenCalled();
done();
});
});
});
Loading