Revert "Revert "Rework Home component phase 2" (#152)" (#153)

This reverts commit 16be9dea8349d076ea67783ab25ca261f45b7a2d.
This commit is contained in:
Alan Friedman 2020-06-11 09:04:10 -04:00 committed by GitHub
parent 16be9dea83
commit d475a148b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 207 additions and 274 deletions

View File

@ -1,4 +1,4 @@
import React, { Component } from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import Message from 'components/Message' import Message from 'components/Message'
import Username from 'components/Username' import Username from 'components/Username'
@ -8,143 +8,128 @@ import { getObjectUrl } from 'utils/file'
import T from 'components/T' import T from 'components/T'
class Activity extends Component { const FileDisplay = ({ activity: { fileType, encodedFile, fileName, username }, scrollToBottom }) => {
constructor(props) { const zoomableImage = React.useRef(null)
super(props)
this.state = { const handleImageDisplay = () => {
zoomableImages: [], Zoom(zoomableImage.current)
} scrollToBottom()
} }
getFileDisplay(activity) { if (fileType.match('image.*')) {
const type = activity.fileType return (
if (type.match('image.*')) { <img
ref={zoomableImage}
className="image-transfer zoomable"
src={`data:${fileType};base64,${encodedFile}`}
alt={`${fileName} from ${username}`}
onLoad={handleImageDisplay}
/>
)
}
return null
}
const Activity = ({ activity, scrollToBottom }) => {
switch (activity.type) {
case 'TEXT_MESSAGE':
return ( return (
<img <Message
ref={c => this._zoomableImage = c} sender={activity.username}
className="image-transfer zoomable" message={activity.text}
src={`data:${activity.fileType};base64,${activity.encodedFile}`} timestamp={activity.timestamp}
alt={`${activity.fileName} from ${activity.username}`}
onLoad={this.handleImageDisplay.bind(this)}
/> />
) )
} case 'USER_ENTER':
return null return (
} <Notice>
<div>
handleImageDisplay() { <T data={{
Zoom(this._zoomableImage) username: <Username key={0} username={activity.username} />
this.props.scrollToBottom() }} path='userJoined'/>
} </div>
</Notice>
getActivityComponent(activity) { )
switch (activity.type) { case 'USER_EXIT':
case 'TEXT_MESSAGE': return (
return ( <Notice>
<Message <div>
sender={activity.username} <T data={{
message={activity.text} username: <Username key={0} username={activity.username} />
timestamp={activity.timestamp} }} path='userLeft'/>
/> </div>
) </Notice>
case 'USER_ENTER': )
return ( case 'TOGGLE_LOCK_ROOM':
<Notice> if (activity.locked) {
<div>
<T data={{
username: <Username key={0} username={activity.username} />
}} path='userJoined'/>
</div>
</Notice>
)
case 'USER_EXIT':
return (
<Notice>
<div>
<T data={{
username: <Username key={0} username={activity.username} />
}} path='userLeft'/>
</div>
</Notice>
)
case 'TOGGLE_LOCK_ROOM':
if (activity.locked) {
return (
<Notice>
<div><T data={{
username: <Username key={0} username={activity.username} />
}} path='lockedRoom'/></div>
</Notice>
)
} else {
return (
<Notice>
<div><T data={{
username: <Username key={0} username={activity.username} />
}} path='unlockedRoom'/></div>
</Notice>
)
}
case 'NOTICE':
return (
<Notice>
<div>{activity.message}</div>
</Notice>
)
case 'CHANGE_USERNAME':
return ( return (
<Notice> <Notice>
<div><T data={{ <div><T data={{
oldUsername: <Username key={0} username={activity.currentUsername} />, username: <Username key={0} username={activity.username} />
newUsername: <Username key={1} username={activity.newUsername} /> }} path='lockedRoom'/></div>
}} path='nameChange'/>
</div>
</Notice> </Notice>
) )
case 'USER_ACTION': } else {
return ( return (
<Notice> <Notice>
<div>&#42; <Username username={activity.username} /> {activity.action}</div> <div><T data={{
username: <Username key={0} username={activity.username} />
}} path='unlockedRoom'/></div>
</Notice> </Notice>
) )
case 'RECEIVE_FILE': }
const downloadUrl = getObjectUrl(activity.encodedFile, activity.fileType) case 'NOTICE':
return ( return (
<Notice>
<div>{activity.message}</div>
</Notice>
)
case 'CHANGE_USERNAME':
return (
<Notice>
<div><T data={{
oldUsername: <Username key={0} username={activity.currentUsername} />,
newUsername: <Username key={1} username={activity.newUsername} />
}} path='nameChange'/>
</div>
</Notice>
)
case 'USER_ACTION':
return (
<Notice>
<div>&#42; <Username username={activity.username} /> {activity.action}</div>
</Notice>
)
case 'RECEIVE_FILE':
const downloadUrl = getObjectUrl(activity.encodedFile, activity.fileType)
return (
<div>
<T data={{
username: <Username key={0} username={activity.username} />,
}} path='userSentFile'/>&nbsp;
<a target="_blank" href={downloadUrl} rel="noopener noreferrer" download={activity.fileName} >
<T data={{
filename: activity.fileName,
}} path='downloadFile'/>
</a>
<FileDisplay activity={activity} scrollToBottom={scrollToBottom} />
</div>
)
case 'SEND_FILE':
const url = getObjectUrl(activity.encodedFile, activity.fileType)
return (
<Notice>
<div> <div>
<T data={{ <T data={{
username: <Username key={0} username={activity.username} />, filename: <a key={0} target="_blank" href={url} rel="noopener noreferrer" download={activity.fileName}>{activity.fileName}</a>,
}} path='userSentFile'/>&nbsp; }} path='sentFile'/>&nbsp;
<a target="_blank" href={downloadUrl} rel="noopener noreferrer" download={activity.fileName}>
<T data={{
filename: activity.fileName,
}} path='downloadFile'/>
</a>
{this.getFileDisplay(activity)}
</div> </div>
) <FileDisplay activity={activity} scrollToBottom={scrollToBottom} />
case 'SEND_FILE': </Notice>
const url = getObjectUrl(activity.encodedFile, activity.fileType) )
return ( default:
<Notice> return false
<div>
<T data={{
filename: <a key={0} target="_blank" href={url} rel="noopener noreferrer" download={activity.fileName}>{activity.fileName}</a>,
}} path='sentFile'/>&nbsp;
</div>
{this.getFileDisplay(activity)}
</Notice>
)
default:
return false
}
}
render() {
return (
this.getActivityComponent(this.props.activity)
)
} }
} }
@ -153,4 +138,4 @@ Activity.propTypes = {
scrollToBottom: PropTypes.func.isRequired, scrollToBottom: PropTypes.func.isRequired,
} }
export default Activity; export default Activity

View File

@ -1,100 +1,90 @@
import React, { Component } from 'react' import React from 'react'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ChatInput from 'components/Chat' import ChatInput from 'components/Chat'
import { defer } from 'lodash'
import Activity from './Activity' import Activity from './Activity'
import T from 'components/T' import T from 'components/T'
import { defer } from 'lodash'
import styles from './styles.module.scss' import styles from './styles.module.scss'
class ActivityList extends Component { const ActivityList = ({ activities, openModal }) => {
constructor(props) { const [focusChat, setFocusChat] = React.useState(false);
super(props) const [scrolledToBottom, setScrolledToBottom] = React.useState(true);
const messageStream = React.useRef(null);
const activitiesList = React.useRef(null);
this.state = { React.useEffect(() => {
zoomableImages: [], const currentMessageStream = messageStream.current;
focusChat: false,
}
}
componentDidMount() { // Update scrolledToBottom state if we scroll the activity stream
this.bindEvents() const onScroll = () => {
} const messageStreamHeight = messageStream.current.clientHeight
const activitiesListHeight = activitiesList.current.clientHeight
componentDidUpdate(prevProps) { const bodyRect = document.body.getBoundingClientRect()
if (prevProps.activities.length < this.props.activities.length) { const elemRect = activitiesList.current.getBoundingClientRect()
this.scrollToBottomIfShould() const offset = elemRect.top - bodyRect.top
} const activitiesListYPos = offset
}
onScroll() { const newScrolledToBottom = (activitiesListHeight + (activitiesListYPos - 60)) <= messageStreamHeight
const messageStreamHeight = this.messageStream.clientHeight if (newScrolledToBottom) {
const activitiesListHeight = this.activitiesList.clientHeight if (!scrolledToBottom) {
setScrolledToBottom(true)
const bodyRect = document.body.getBoundingClientRect() }
const elemRect = this.activitiesList.getBoundingClientRect() } else if (scrolledToBottom) {
const offset = elemRect.top - bodyRect.top setScrolledToBottom(false)
const activitiesListYPos = offset
const scrolledToBottom = (activitiesListHeight + (activitiesListYPos - 60)) <= messageStreamHeight
if (scrolledToBottom) {
if (!this.props.scrolledToBottom) {
this.props.setScrolledToBottom(true)
} }
} else if (this.props.scrolledToBottom) {
this.props.setScrolledToBottom(false)
} }
}
scrollToBottomIfShould() { currentMessageStream.addEventListener('scroll', onScroll)
if (this.props.scrolledToBottom) { return () => {
setTimeout(() => { // Unbind event if component unmounted
this.messageStream.scrollTop = this.messageStream.scrollHeight currentMessageStream.removeEventListener('scroll', onScroll)
}, 0)
} }
}, [scrolledToBottom])
const scrollToBottomIfShould = React.useCallback(() => {
if (scrolledToBottom) {
messageStream.current.scrollTop = messageStream.current.scrollHeight
}
}, [scrolledToBottom])
React.useEffect(() => {
scrollToBottomIfShould(); // Only if activities.length bigger
}, [scrollToBottomIfShould, activities]);
const scrollToBottom = React.useCallback(() => {
messageStream.current.scrollTop = messageStream.current.scrollHeight
setScrolledToBottom(true)
}, [])
const handleChatClick = () => {
setFocusChat(true);
defer(() => setFocusChat(false))
} }
scrollToBottom() { return (
this.messageStream.scrollTop = this.messageStream.scrollHeight <div className="main-chat">
this.props.setScrolledToBottom(true) <div onClick={handleChatClick} className="message-stream h-100" ref={messageStream} data-testid="main-div">
} <ul className="plain" ref={activitiesList}>
<li><p className={styles.tos}><button className='btn btn-link' onClick={() => openModal('About')}> <T path='agreement'/></button></p></li>
bindEvents() { {activities.map((activity, index) => (
this.messageStream.addEventListener('scroll', this.onScroll.bind(this))
}
handleChatClick() {
this.setState({ focusChat: true })
defer(() => this.setState({ focusChat: false }))
}
render() {
return (
<div className="main-chat">
<div onClick={this.handleChatClick.bind(this)} className="message-stream h-100" ref={el => this.messageStream = el} data-testid="main-div">
<ul className="plain" ref={el => this.activitiesList = el}>
<li><p className={styles.tos}><button className='btn btn-link' onClick={this.props.openModal.bind(this, 'About')}> <T path='agreement'/></button></p></li>
{this.props.activities.map((activity, index) => (
<li key={index} className={`activity-item ${activity.type}`}> <li key={index} className={`activity-item ${activity.type}`}>
<Activity activity={activity} scrollToBottom={this.scrollToBottomIfShould.bind(this)} /> <Activity activity={activity} scrollToBottom={scrollToBottomIfShould} />
</li> </li>
))} ))}
</ul> </ul>
</div>
<div className="chat-container">
<ChatInput scrollToBottom={this.scrollToBottom.bind(this)} focusChat={this.state.focusChat} />
</div>
</div> </div>
) <div className="chat-container">
} <ChatInput scrollToBottom={scrollToBottom} focusChat={focusChat} />
</div>
</div>
)
} }
ActivityList.propTypes = { ActivityList.propTypes = {
activities: PropTypes.array.isRequired, activities: PropTypes.array.isRequired,
openModal: PropTypes.func.isRequired, openModal: PropTypes.func.isRequired,
setScrolledToBottom: PropTypes.func.isRequired,
scrolledToBottom: PropTypes.bool.isRequired,
} }
export default ActivityList; export default ActivityList

View File

@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react'; import { render, fireEvent } from '@testing-library/react';
import ActivityList from './ActivityList'; import ActivityList from './ActivityList';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import configureStore from 'store'; import configureStore from 'store';
@ -12,7 +12,7 @@ describe('ActivityList component', () => {
it('should display', () => { it('should display', () => {
const { asFragment } = render( const { asFragment } = render(
<Provider store={store}> <Provider store={store}>
<ActivityList openModal={jest.fn()} activities={[]} setScrolledToBottom={jest.fn()} scrolledToBottom /> <ActivityList openModal={jest.fn()} activities={[]} />
</Provider>, </Provider>,
); );
@ -39,7 +39,7 @@ describe('ActivityList component', () => {
]; ];
const { asFragment } = render( const { asFragment } = render(
<Provider store={store}> <Provider store={store}>
<ActivityList openModal={jest.fn()} activities={activities} setScrolledToBottom={jest.fn()} scrolledToBottom /> <ActivityList openModal={jest.fn()} activities={activities} />
</Provider>, </Provider>,
); );
@ -51,7 +51,7 @@ describe('ActivityList component', () => {
const { getByText } = render( const { getByText } = render(
<Provider store={store}> <Provider store={store}>
<ActivityList openModal={mockOpenModal} activities={[]} setScrolledToBottom={jest.fn()} scrolledToBottom /> <ActivityList openModal={mockOpenModal} activities={[]} />
</Provider>, </Provider>,
); );
@ -59,79 +59,33 @@ describe('ActivityList component', () => {
jest.runAllTimers(); jest.runAllTimers();
expect(mockOpenModal.mock.calls[0][0]).toBe('About'); expect(mockOpenModal.mock.calls[0][0]).toBe('About');
jest.runAllTimers();
}); });
it('should focus chat', () => { it('should focus chat', () => {
const { getByTestId } = render( const { getByTestId } = render(
<Provider store={store}> <Provider store={store}>
<ActivityList openModal={jest.fn()} activities={[]} setScrolledToBottom={jest.fn()} scrolledToBottom /> <ActivityList openModal={jest.fn()} activities={[]} />
</Provider>, </Provider>,
); );
fireEvent.click(getByTestId('main-div')); fireEvent.click(getByTestId('main-div'));
jest.runAllTimers(); jest.runAllTimers();
}); });
it('should handle scroll', () => { it('should scroll to bottom on new message if not scrolled', () => {
const mockSetScrollToBottom = jest.fn(); jest.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValueOnce(400).mockReturnValueOnce(200);
jest
.spyOn(Element.prototype, 'clientHeight', 'get')
.mockReturnValueOnce(400)
.mockReturnValueOnce(200)
.mockReturnValueOnce(400)
.mockReturnValueOnce(200);
Element.prototype.getBoundingClientRect = jest Element.prototype.getBoundingClientRect = jest
.fn() .fn()
.mockReturnValueOnce({ top: 0 }) .mockReturnValueOnce({ top: 0 })
.mockReturnValueOnce({ top: 60 })
.mockReturnValueOnce({ top: 0 })
.mockReturnValueOnce({ top: 261 }); .mockReturnValueOnce({ top: 261 });
const { getByTestId, rerender } = render(
<Provider store={store}>
<ActivityList
openModal={jest.fn()}
activities={[]}
setScrolledToBottom={mockSetScrollToBottom}
scrolledToBottom={false}
/>
</Provider>,
);
fireEvent.scroll(getByTestId('main-div'));
expect(mockSetScrollToBottom).toHaveBeenLastCalledWith(true);
rerender(
<Provider store={store}>
<ActivityList
openModal={jest.fn()}
activities={[]}
setScrolledToBottom={mockSetScrollToBottom}
scrolledToBottom={true}
/>
</Provider>,
);
fireEvent.scroll(getByTestId('main-div'));
expect(mockSetScrollToBottom).toHaveBeenCalledTimes(2);
expect(mockSetScrollToBottom).toHaveBeenLastCalledWith(false);
});
it('should scroll to bottom on new message', () => {
const mockSetScrollToBottom = jest.fn();
jest.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(42); jest.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(42);
const mockScrollTop = jest.spyOn(Element.prototype, 'scrollTop', 'set'); const mockScrollTop = jest.spyOn(Element.prototype, 'scrollTop', 'set');
const { rerender } = render( const { rerender, getByTestId } = render(
<Provider store={store}> <Provider store={store}>
<ActivityList <ActivityList openModal={jest.fn()} activities={[]} />
openModal={jest.fn()}
activities={[]}
setScrolledToBottom={mockSetScrollToBottom}
scrolledToBottom={true}
/>
</Provider>, </Provider>,
); );
@ -147,14 +101,39 @@ describe('ActivityList component', () => {
text: 'Hi!', text: 'Hi!',
}, },
]} ]}
setScrolledToBottom={mockSetScrollToBottom}
scrolledToBottom={true}
/> />
</Provider>, </Provider>,
); );
jest.runAllTimers(); jest.runAllTimers();
expect(mockScrollTop).toHaveBeenCalledTimes(2);
expect(mockScrollTop).toHaveBeenLastCalledWith(42); expect(mockScrollTop).toHaveBeenLastCalledWith(42);
fireEvent.scroll(getByTestId('main-div'));
rerender(
<Provider store={store}>
<ActivityList
openModal={jest.fn()}
activities={[
{
type: 'TEXT_MESSAGE',
username: 'alice',
timestamp: new Date('2020-03-14T11:01:58.135Z').valueOf(),
text: 'Hi!',
},
{
type: 'TEXT_MESSAGE',
username: 'alice',
timestamp: new Date('2020-03-14T11:01:59.135Z').valueOf(),
text: 'Hi! every body',
},
]}
/>
</Provider>,
);
expect(mockScrollTop).toHaveBeenCalledTimes(2);
}); });
}); });

View File

@ -11,7 +11,6 @@ import Settings from 'components/Settings'
import Welcome from 'components/Welcome' import Welcome from 'components/Welcome'
import RoomLocked from 'components/RoomLocked' import RoomLocked from 'components/RoomLocked'
import { X, AlertCircle } from 'react-feather' import { X, AlertCircle } from 'react-feather'
import { defer } from 'lodash'
import Tinycon from 'tinycon' import Tinycon from 'tinycon'
import beepFile from 'audio/beep.mp3' import beepFile from 'audio/beep.mp3'
import classNames from 'classnames' import classNames from 'classnames'
@ -24,13 +23,6 @@ const crypto = new Crypto()
Modal.setAppElement('#root'); Modal.setAppElement('#root');
class Home extends Component { class Home extends Component {
constructor(props) {
super(props)
this.state = {
zoomableImages: []
}
}
async componentWillMount() { async componentWillMount() {
const roomId = encodeURI(this.props.match.params.roomId) const roomId = encodeURI(this.props.match.params.roomId)
@ -145,7 +137,6 @@ class Home extends Component {
} }
bindEvents() { bindEvents() {
window.onfocus = () => { window.onfocus = () => {
this.props.toggleWindowFocus(true) this.props.toggleWindowFocus(true)
} }
@ -175,11 +166,6 @@ class Home extends Component {
}) })
} }
handleChatClick() {
this.setState({ focusChat: true })
defer(() => this.setState({ focusChat: false }))
}
render() { render() {
const modalOpts = this.getModal() const modalOpts = this.getModal()
return ( return (
@ -201,11 +187,9 @@ class Home extends Component {
translations={this.props.translations} translations={this.props.translations}
/> />
</div> </div>
<ActivityList <ActivityList
openModal={this.props.openModal} openModal={this.props.openModal}
activities={this.props.activities} activities={this.props.activities}
setScrolledToBottom={this.props.setScrolledToBottom}
scrolledToBottom={this.props.scrolledToBottom}
/> />
<Modal <Modal
isOpen={Boolean(this.props.modalComponent)} isOpen={Boolean(this.props.modalComponent)}
@ -261,8 +245,6 @@ Home.propTypes = {
modalComponent: PropTypes.string, modalComponent: PropTypes.string,
openModal: PropTypes.func.isRequired, openModal: PropTypes.func.isRequired,
closeModal: PropTypes.func.isRequired, closeModal: PropTypes.func.isRequired,
setScrolledToBottom: PropTypes.func.isRequired,
scrolledToBottom: PropTypes.bool.isRequired,
iAmOwner: PropTypes.bool.isRequired, iAmOwner: PropTypes.bool.isRequired,
userId: PropTypes.string.isRequired, userId: PropTypes.string.isRequired,
toggleWindowFocus: PropTypes.func.isRequired, toggleWindowFocus: PropTypes.func.isRequired,

View File

@ -5,7 +5,6 @@ import {
createUser, createUser,
openModal, openModal,
closeModal, closeModal,
setScrolledToBottom,
toggleWindowFocus, toggleWindowFocus,
toggleSoundEnabled, toggleSoundEnabled,
toggleSocketConnected, toggleSocketConnected,
@ -28,7 +27,6 @@ const mapStateToProps = (state) => {
roomId: state.room.id, roomId: state.room.id,
roomLocked: state.room.isLocked, roomLocked: state.room.isLocked,
modalComponent: state.app.modalComponent, modalComponent: state.app.modalComponent,
scrolledToBottom: state.app.scrolledToBottom,
iAmOwner: Boolean(me && me.isOwner), iAmOwner: Boolean(me && me.isOwner),
faviconCount: state.app.unreadMessageCount, faviconCount: state.app.unreadMessageCount,
soundIsEnabled: state.app.soundIsEnabled, soundIsEnabled: state.app.soundIsEnabled,
@ -43,7 +41,6 @@ const mapDispatchToProps = {
createUser, createUser,
openModal, openModal,
closeModal, closeModal,
setScrolledToBottom,
toggleWindowFocus, toggleWindowFocus,
toggleSoundEnabled, toggleSoundEnabled,
toggleSocketConnected, toggleSocketConnected,