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

Video 3728 add input field and send button #451

Merged
merged 14 commits into from
Mar 11, 2021
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
82 changes: 82 additions & 0 deletions src/components/ChatWindow/ChatInput/ChatInput.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React from 'react';
import { shallow } from 'enzyme';
import TextareaAutosize from '@material-ui/core/TextareaAutosize';
import useMediaQuery from '@material-ui/core/useMediaQuery';

import ChatInput from '../ChatInput/ChatInput';
import SendMessageIcon from '../../../icons/SendMessageIcon';

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

const mockUseMediaQuery = useMediaQuery as jest.Mock<boolean>;
const mockHandleSendMessage = jest.fn();

describe('the ChatInput component', () => {
beforeAll(() => {
mockUseMediaQuery.mockImplementation(() => false);
});

afterEach(jest.clearAllMocks);

it('should enable the send message button when user types a valid message', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
expect(
wrapper
.find(SendMessageIcon)
.parent()
.prop('disabled')
).toBe(true);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: 'I am a message!!!' } });
expect(
wrapper
.find(SendMessageIcon)
.parent()
.prop('disabled')
).toBe(false);
});

it('should disable the send message button when message only contains whitespace', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: ' ' } });
expect(
wrapper
.find(SendMessageIcon)
.parent()
.prop('disabled')
).toBe(true);
});

it('should call the correct function when send message button is clicked', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: ' I am a message!!! \n ' } });
wrapper
.find(SendMessageIcon)
.parent()
.simulate('click');
expect(mockHandleSendMessage).toHaveBeenCalledWith('I am a message!!!');
});

it('should only send a message and reset the textarea when Enter is pressed', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: ' I am a message!!!' } });
wrapper.find(TextareaAutosize).simulate('keypress', { preventDefault() {}, key: 'Enter' });
expect(mockHandleSendMessage).toHaveBeenCalledWith('I am a message!!!');
expect(wrapper.find(TextareaAutosize).prop('value')).toBe('');
});

it('should not send a message when Enter is pressed on mobile', () => {
mockUseMediaQuery.mockImplementationOnce(() => true);
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: 'I am a message!!!' } });
wrapper.find(TextareaAutosize).simulate('keypress', { key: 'enter' });
expect(wrapper.find(TextareaAutosize).prop('value')).toBe('I am a message!!!');
expect(mockHandleSendMessage).not.toHaveBeenCalled();
});

it('should not send a message when a user presses Enter+Shift', () => {
const wrapper = shallow(<ChatInput conversation={{ sendMessage: mockHandleSendMessage } as any} />);
wrapper.find(TextareaAutosize).simulate('change', { target: { value: 'I am a message!!!' } });
wrapper.find(TextareaAutosize).simulate('keypress', { key: 'Enter', shiftKey: true });
expect(mockHandleSendMessage).not.toHaveBeenCalled();
});
Copy link
Contributor

@timmydoza timmydoza Mar 10, 2021

Choose a reason for hiding this comment

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

All these tests look good! But I think we could use some additional tests for the handleReturnKeyPress function. We'll need to test that it works correctly for the different values of isMobile, event.key, and event.shiftKey.

CircleCI provides a test coverage report for each PR (which can also be generated locally with npm run test:ci), which can be used to see if there are any tests that are missing: https://1225-216070925-gh.circle-artifacts.com/0/coverage/lcov-report/src/components/ChatWindow/ChatInput/ChatInput.tsx.html

});
89 changes: 89 additions & 0 deletions src/components/ChatWindow/ChatInput/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import SendMessageIcon from '../../../icons/SendMessageIcon';
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: {
borderTop: '1px solid #e4e7e9',
borderBottom: '1px solid #e4e7e9',
padding: '1em 1.2em 1em',
},
textArea: {
padding: '0.75em 1em',
marginTop: '0.4em',
width: '100%',
border: '0',
resize: 'none',
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',
'&:disabled': {
background: 'none',
cursor: 'default',
},
},
});

interface ChatInputProps {
conversation: Conversation;
}

export default function ChatInput({ conversation }: ChatInputProps) {
const classes = useStyles();
const [messageBody, setMessageBody] = useState('');
const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm'));
const isValidMessage = /\S/.test(messageBody);

const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessageBody(event.target.value);
};

// ensures pressing enter + shift creates a new line, so that enter on its own only sends the message:
const handleReturnKeyPress = (event: React.KeyboardEvent) => {
if (!isMobile && event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSendMessage(messageBody);
}
};

const handleSendMessage = (message: string) => {
if (isValidMessage) {
conversation.sendMessage(message.trim());
setMessageBody('');
}
};

return (
<div className={classes.chatInputContainer}>
<TextareaAutosize
rowsMin={1}
rowsMax={3}
className={classes.textArea}
aria-label="chat input"
placeholder="Write a message..."
autoFocus
onKeyPress={handleReturnKeyPress}
onChange={handleChange}
value={messageBody}
/>

<button className={classes.sendButton} onClick={() => handleSendMessage(messageBody)} disabled={!isValidMessage}>
<SendMessageIcon />
</button>
</div>
);
}
7 changes: 5 additions & 2 deletions src/components/ChatWindow/ChatWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import React from 'react';
import { makeStyles, createStyles, Theme } from '@material-ui/core/styles';
import ChatWindowHeader from './ChatWindowHeader/ChatWindowHeader';
import ChatInput from './ChatInput/ChatInput';
import MessageList from './MessageList/MessageList';
import useChatContext from '../../hooks/useChatContext/useChatContext';

const useStyles = makeStyles((theme: Theme) =>
createStyles({
chatWindowContainer: {
overflowY: 'auto',
background: '#FFFFFF',
zIndex: 100,
display: 'flex',
flexDirection: 'column',
[theme.breakpoints.down('sm')]: {
position: 'fixed',
top: 0,
Expand All @@ -23,7 +25,7 @@ const useStyles = makeStyles((theme: Theme) =>

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

//if chat window is not open, don't render this component
if (!isChatWindowOpen) return null;
Expand All @@ -32,6 +34,7 @@ export default function ChatWindow() {
<aside className={classes.chatWindowContainer}>
<ChatWindowHeader />
<MessageList messages={messages} />
<ChatInput conversation={conversation!} />
</aside>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const useStyles = makeStyles(() =>
closeChatWindow: {
cursor: 'pointer',
display: 'flex',
background: 'transparent',
border: '0',
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: I think adding padding: '0.4em' helps a little. It makes the blue border square when it is focused. Previously it was a little rectangular.

image

padding: '0.4em',
},
})
);
Expand All @@ -32,9 +35,9 @@ export default function ChatWindowHeader() {
return (
<div className={classes.container}>
<div className={classes.text}>Chat</div>
<div className={classes.closeChatWindow} onClick={() => setIsChatWindowOpen(false)}>
<button className={classes.closeChatWindow} onClick={() => setIsChatWindowOpen(false)}>
<CloseIcon />
</div>
</button>
</div>
);
}
16 changes: 13 additions & 3 deletions src/components/ChatWindow/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,29 @@
import React from 'react';
import TextMessage from './TextMessage/TextMessage';
import MessageInfo from './MessageInfo/MessageInfo';
import { makeStyles } from '@material-ui/core/styles';
import { Message } from '@twilio/conversations/lib/message';
import MessageInfo from './MessageInfo/MessageInfo';
import TextMessage from './TextMessage/TextMessage';
import useVideoContext from '../../../hooks/useVideoContext/useVideoContext';

interface MessageListProps {
messages: Message[];
}

const useStyles = makeStyles({
messageListContainer: {
padding: '0 1.2em 1em',
overflowY: 'auto',
flex: 1,
},
});

export default function MessageList({ messages }: MessageListProps) {
const classes = useStyles();
const { room } = useVideoContext();
const localParticipant = room!.localParticipant;

return (
<div style={{ padding: '0 1.2em' }}>
<div className={classes.messageListContainer}>
{messages.map((message, idx) => {
const time = message.dateCreated
.toLocaleTimeString('en-us', { hour: 'numeric', minute: 'numeric' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
exports[`the messageList component should render correctly 1`] = `
<div>
<div
style="padding: 0px 1.2em;"
class="makeStyles-messageListContainer-1"
>
<div
class="makeStyles-messageInfoContainer-1"
class="makeStyles-messageInfoContainer-2"
>
<div>
olivia (You)
Expand All @@ -17,15 +17,15 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2 makeStyles-isLocalParticipant-3"
class="makeStyles-messageContainer-3 makeStyles-isLocalParticipant-4"
>
<div>
This is a message
</div>
</div>
</div>
<div
class="makeStyles-messageInfoContainer-1"
class="makeStyles-messageInfoContainer-2"
>
<div>
tim
Expand All @@ -36,7 +36,7 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2"
class="makeStyles-messageContainer-3"
>
<div>
Hi Olivia!
Expand All @@ -45,7 +45,7 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2"
class="makeStyles-messageContainer-3"
>
<div>
That is pretty rad double line message! How did you do that?
Expand All @@ -54,15 +54,15 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2"
class="makeStyles-messageContainer-3"
>
<div>
😉
</div>
</div>
</div>
<div
class="makeStyles-messageInfoContainer-1"
class="makeStyles-messageInfoContainer-2"
>
<div>
olivia (You)
Expand All @@ -73,7 +73,7 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2 makeStyles-isLocalParticipant-3"
class="makeStyles-messageContainer-3 makeStyles-isLocalParticipant-4"
>
<div>
Magic
Expand All @@ -82,15 +82,15 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2 makeStyles-isLocalParticipant-3"
class="makeStyles-messageContainer-3 makeStyles-isLocalParticipant-4"
>
<div>
lots of magic
</div>
</div>
</div>
<div
class="makeStyles-messageInfoContainer-1"
class="makeStyles-messageInfoContainer-2"
>
<div>
tim
Expand All @@ -101,7 +101,7 @@ exports[`the messageList component should render correctly 1`] = `
</div>
<div>
<div
class="makeStyles-messageContainer-2"
class="makeStyles-messageContainer-3"
>
<div>
look at this app:
Expand Down
20 changes: 20 additions & 0 deletions src/icons/SendMessageIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

export default function ScreenShareIcon() {
return (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.3539 1.64645C22.5491 1.84171 22.5491 2.15829 22.3539 2.35355L9.74515 14.9622C9.54989 15.1575 9.23331 15.1575 9.03805 14.9622C8.84279 14.767 8.84279 14.4504 9.03805 14.2551L21.6467 1.64645C21.842 1.45118 22.1586 1.45118 22.3539 1.64645Z"
fill="#D8D8D8"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M22.3536 1.64646C22.4988 1.79171 22.5404 2.011 22.4585 2.19938L13.7629 22.1994C13.6863 22.3756 13.5155 22.4923 13.3235 22.4996C13.1316 22.507 12.9523 22.4037 12.8625 22.234L9.02139 14.9786L1.76606 11.1376C1.59627 11.0477 1.493 10.8685 1.50037 10.6765C1.50774 10.4845 1.62446 10.3137 1.80064 10.2371L21.8006 1.54148C21.989 1.45958 22.2083 1.50122 22.3536 1.64646ZM3.15222 10.7399L9.62525 14.1668C9.71385 14.2137 9.7863 14.2862 9.8332 14.3748L13.2601 20.8478L21.0354 2.96463L3.15222 10.7399Z"
fill="#D8D8D8"
/>
</svg>
);
}