logo

mastofe

My custom branche(s) on git.pleroma.social/pleroma/mastofe
commit: 8ee2eb5d2e7bd3c601c0277f12d8ad0c5f84cc43
parent: 20b647020bf8de2af6d2ce44ed76566d137dd1f6
Author: Eugen Rochko <eugen@zeonfederated.com>
Date:   Sun,  4 Jun 2017 01:39:38 +0200

Allow mounting arbitrary columns (#3207)

* Allow mounting arbitrary columns

* Refactor column headers, allow pinning/unpinning and moving columns around

* Collapse animation

* Re-introduce scroll to top

* Save column settings properly, do not display pin options in
single-column view, do not display collapse icon if there is
nothing to collapse

* Fix one instance of public timeline being closed closing the stream
Fix back buttons inconsistently sending you back to / even if history exists

* Getting started displays links to columns that are not mounted

Diffstat:

Aapp/javascript/mastodon/actions/columns.js40++++++++++++++++++++++++++++++++++++++++
Aapp/javascript/mastodon/components/column.js45+++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/components/column_back_button.js2+-
Mapp/javascript/mastodon/components/column_back_button_slim.js3++-
Aapp/javascript/mastodon/components/column_header.js138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mapp/javascript/mastodon/features/community_timeline/index.js67++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mapp/javascript/mastodon/features/compose/index.js6+++---
Mapp/javascript/mastodon/features/getting_started/index.js45++++++++++++++++++++++++++++++++++++---------
Mapp/javascript/mastodon/features/hashtag_timeline/index.js54+++++++++++++++++++++++++++++++++++++++++++++++++-----
Mapp/javascript/mastodon/features/home_timeline/components/column_settings.js26++++++++++++--------------
Mapp/javascript/mastodon/features/home_timeline/index.js50+++++++++++++++++++++++++++++++++++++++++++++-----
Mapp/javascript/mastodon/features/notifications/components/column_settings.js52+++++++++++++++++++++++++---------------------------
Mapp/javascript/mastodon/features/notifications/index.js51+++++++++++++++++++++++++++++++++++++++++++++------
Mapp/javascript/mastodon/features/public_timeline/index.js67++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mapp/javascript/mastodon/features/ui/components/column.js31+++----------------------------
Mapp/javascript/mastodon/features/ui/components/columns_area.js39+++++++++++++++++++++++++++++++++++++--
Aapp/javascript/mastodon/features/ui/containers/columns_area_container.js8++++++++
Mapp/javascript/mastodon/features/ui/index.js28++--------------------------
Mapp/javascript/mastodon/reducers/settings.js27+++++++++++++++++++++++++++
Aapp/javascript/mastodon/scroll.js29+++++++++++++++++++++++++++++
Mapp/javascript/styles/components.scss99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
21 files changed, 754 insertions(+), 153 deletions(-)

diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js @@ -0,0 +1,40 @@ +import { saveSettings } from './settings'; + +export const COLUMN_ADD = 'COLUMN_ADD'; +export const COLUMN_REMOVE = 'COLUMN_REMOVE'; +export const COLUMN_MOVE = 'COLUMN_MOVE'; + +export function addColumn(id, params) { + return dispatch => { + dispatch({ + type: COLUMN_ADD, + id, + params, + }); + + dispatch(saveSettings()); + }; +}; + +export function removeColumn(uuid) { + return dispatch => { + dispatch({ + type: COLUMN_REMOVE, + uuid, + }); + + dispatch(saveSettings()); + }; +}; + +export function moveColumn(uuid, direction) { + return dispatch => { + dispatch({ + type: COLUMN_MOVE, + uuid, + direction, + }); + + dispatch(saveSettings()); + }; +}; diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js @@ -0,0 +1,45 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import scrollTop from '../scroll'; + +class Column extends React.PureComponent { + + static propTypes = { + children: PropTypes.node, + }; + + scrollTop () { + const scrollable = this.node.querySelector('.scrollable'); + + if (!scrollable) { + return; + } + + this._interruptScrollAnimation = scrollTop(scrollable); + } + + handleWheel = () => { + if (typeof this._interruptScrollAnimation !== 'function') { + return; + } + + this._interruptScrollAnimation(); + } + + setRef = c => { + this.node = c; + } + + render () { + const { children } = this.props; + + return ( + <div role='region' className='column' ref={this.setRef} onWheel={this.handleWheel}> + {children} + </div> + ); + } + +} + +export default Column; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js @@ -9,7 +9,7 @@ class ColumnBackButton extends React.PureComponent { }; handleClick = () => { - if (window.history && window.history.length === 1) this.context.router.push("/"); + if (window.history && window.history.length === 1) this.context.router.push('/'); else this.context.router.goBack(); } diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js @@ -9,7 +9,8 @@ class ColumnBackButtonSlim extends React.PureComponent { }; handleClick = () => { - this.context.router.push('/'); + if (window.history && window.history.length === 1) this.context.router.push('/'); + else this.context.router.goBack(); } render () { diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js @@ -0,0 +1,138 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { FormattedMessage } from 'react-intl'; + +class ColumnHeader extends React.PureComponent { + + static contextTypes = { + router: PropTypes.object, + }; + + static propTypes = { + title: PropTypes.string.isRequired, + icon: PropTypes.string.isRequired, + active: PropTypes.bool, + multiColumn: PropTypes.bool, + children: PropTypes.node, + pinned: PropTypes.bool, + onPin: PropTypes.func, + onMove: PropTypes.func, + onClick: PropTypes.func, + }; + + state = { + collapsed: true, + animating: false, + }; + + handleToggleClick = (e) => { + e.stopPropagation(); + this.setState({ collapsed: !this.state.collapsed, animating: true }); + } + + handleTitleClick = () => { + this.props.onClick(); + } + + handleMoveLeft = () => { + this.props.onMove(-1); + } + + handleMoveRight = () => { + this.props.onMove(1); + } + + handleBackClick = () => { + if (window.history && window.history.length === 1) this.context.router.push('/'); + else this.context.router.goBack(); + } + + handleTransitionEnd = () => { + this.setState({ animating: false }); + } + + render () { + const { title, icon, active, children, pinned, onPin, multiColumn } = this.props; + const { collapsed, animating } = this.state; + + const buttonClassName = classNames('column-header', { + 'active': active, + }); + + const collapsibleClassName = classNames('column-header__collapsible', { + 'collapsed': collapsed, + 'animating': animating, + }); + + const collapsibleButtonClassName = classNames('column-header__button', { + 'active': !collapsed, + }); + + let extraContent, pinButton, moveButtons, backButton, collapseButton; + + if (children) { + extraContent = ( + <div key='extra-content' className='column-header__collapsible__extra'> + {children} + </div> + ); + } + + if (multiColumn && pinned) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-times' /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>; + + moveButtons = ( + <div key='move-buttons' className='column-header__setting-arrows'> + <button className='text-btn column-header__setting-btn' onClick={this.handleMoveLeft}><i className='fa fa-chevron-left' /></button> + <button className='text-btn column-header__setting-btn' onClick={this.handleMoveRight}><i className='fa fa-chevron-right' /></button> + </div> + ); + } else if (multiColumn) { + pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={onPin}><i className='fa fa fa-plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; + + backButton = ( + <button onClick={this.handleBackClick} className='column-header__back-button'> + <i className='fa fa-fw fa-chevron-left column-back-button__icon' /> + <FormattedMessage id='column_back_button.label' defaultMessage='Back' /> + </button> + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || multiColumn) { + collapseButton = <button className={collapsibleButtonClassName} onClick={this.handleToggleClick}><i className='fa fa-sliders' /></button>; + } + + return ( + <div> + <div role='button heading' tabIndex='0' className={buttonClassName} onClick={this.handleTitleClick}> + <i className={`fa fa-fw fa-${icon} column-header__icon`} /> + {title} + + <div className='column-header__buttons'> + {backButton} + {collapseButton} + </div> + </div> + + <div className={collapsibleClassName} onTransitionEnd={this.handleTransitionEnd}> + <div> + {(!collapsed || animating) && collapsedContent} + </div> + </div> + </div> + ); + } + +} + +export default ColumnHeader; diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js @@ -2,7 +2,8 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; -import Column from '../ui/components/column'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; import { refreshTimeline, updateTimeline, @@ -10,6 +11,7 @@ import { connectTimeline, disconnectTimeline, } from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import createStream from '../../stream'; @@ -24,28 +26,47 @@ const mapStateToProps = state => ({ accessToken: state.getIn(['meta', 'access_token']), }); -let subscription; - class CommunityTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, intl: PropTypes.object.isRequired, streamingAPIBaseURL: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, }; + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('COMMUNITY', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + componentDidMount () { const { dispatch, streamingAPIBaseURL, accessToken } = this.props; dispatch(refreshTimeline('community')); - if (typeof subscription !== 'undefined') { + if (typeof this._subscription !== 'undefined') { return; } - subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { + this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { connected () { dispatch(connectTimeline('community')); @@ -74,19 +95,39 @@ class CommunityTimeline extends React.PureComponent { } componentWillUnmount () { - // if (typeof subscription !== 'undefined') { - // subscription.close(); - // subscription = null; - // } + if (typeof this._subscription !== 'undefined') { + this._subscription.close(); + this._subscription = null; + } + } + + setRef = c => { + this.column = c; } render () { - const { intl, hasUnread } = this.props; + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; return ( - <Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> - <ColumnBackButtonSlim /> - <StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> + <Column ref={this.setRef}> + <ColumnHeader + icon='users' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + <StatusListContainer + {...this.props} + scrollKey={`community_timeline-${columnId}`} + type='community' + emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} + /> </Column> ); } diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js @@ -28,7 +28,7 @@ class Compose extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, - withHeader: PropTypes.bool, + multiColumn: PropTypes.bool, showSearch: PropTypes.bool, intl: PropTypes.object.isRequired, }; @@ -42,11 +42,11 @@ class Compose extends React.PureComponent { } render () { - const { withHeader, showSearch, intl } = this.props; + const { multiColumn, showSearch, intl } = this.props; let header = ''; - if (withHeader) { + if (multiColumn) { header = ( <div className='drawer__header'> <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link> diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js @@ -11,6 +11,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; const messages = defineMessages({ heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, + home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, + notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, @@ -26,6 +28,7 @@ const messages = defineMessages({ const mapStateToProps = state => ({ me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), + columns: state.getIn(['settings', 'columns']), }); class GettingStarted extends ImmutablePureComponent { @@ -33,27 +36,51 @@ class GettingStarted extends ImmutablePureComponent { static propTypes = { intl: PropTypes.object.isRequired, me: ImmutablePropTypes.map.isRequired, + columns: ImmutablePropTypes.list, + multiColumn: PropTypes.bool, }; render () { - const { intl, me } = this.props; + const { intl, me, columns, multiColumn } = this.props; - let followRequests = ''; + let navItems = []; + + if (multiColumn) { + if (!columns.find(item => item.get('id') === 'HOME')) { + navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} to='/notifications' />); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />); + } + } + + navItems = navItems.concat([ + <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, + ]); if (me.get('locked')) { - followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />; + navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); } + navItems = navItems.concat([ + <ColumnLink key='6' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, + <ColumnLink key='7' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, + ]); + return ( <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}> <div className='getting-started__wrapper'> <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/> - <ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> - <ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> - <ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> - {followRequests} - <ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> - <ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> + {navItems} <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/> <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js @@ -2,12 +2,14 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; -import Column from '../ui/components/column'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; import { refreshTimeline, updateTimeline, deleteFromTimelines, } from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { FormattedMessage } from 'react-intl'; import createStream from '../../stream'; @@ -22,12 +24,33 @@ class HashtagTimeline extends React.PureComponent { static propTypes = { params: PropTypes.object.isRequired, + columnId: PropTypes.string, dispatch: PropTypes.func.isRequired, streamingAPIBaseURL: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, }; + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HASHTAG', { id: this.props.params.id })); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + _subscribe (dispatch, id) { const { streamingAPIBaseURL, accessToken } = this.props; @@ -74,13 +97,34 @@ class HashtagTimeline extends React.PureComponent { this._unsubscribe(); } + setRef = c => { + this.column = c; + } + render () { - const { id, hasUnread } = this.props.params; + const { hasUnread, columnId, multiColumn } = this.props; + const { id } = this.props.params; + const pinned = !!columnId; return ( - <Column icon='hashtag' active={hasUnread} heading={id}> - <ColumnBackButtonSlim /> - <StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> + <Column ref={this.setRef}> + <ColumnHeader + icon='hashtag' + active={hasUnread} + title={id} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + <StatusListContainer + scrollKey={`hashtag_timeline-${columnId}`} + type='tag' + id={id} + emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} + /> </Column> ); } diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js @@ -24,25 +24,23 @@ class ColumnSettings extends React.PureComponent { const { settings, onChange, onSave, intl } = this.props; return ( - <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}> - <div className='column-settings__outer'> - <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> + <div> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> + </div> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> + </div> - <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> + <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> - <div className='column-settings__row'> - <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> - </div> + <div className='column-settings__row'> + <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> </div> - </ColumnCollapsable> + </div> ); } diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js @@ -2,7 +2,9 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; -import Column from '../ui/components/column'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnSettingsContainer from './containers/column_settings_container'; import Link from 'react-router/lib/Link'; @@ -19,13 +21,40 @@ const mapStateToProps = state => ({ class HomeTimeline extends React.PureComponent { static propTypes = { + dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, hasUnread: PropTypes.bool, hasFollows: PropTypes.bool, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, }; + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('HOME', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + render () { - const { intl, hasUnread, hasFollows } = this.props; + const { intl, hasUnread, hasFollows, columnId, multiColumn } = this.props; + const pinned = !!columnId; let emptyMessage; @@ -36,12 +65,23 @@ class HomeTimeline extends React.PureComponent { } return ( - <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> - <ColumnSettingsContainer /> + <Column ref={this.setRef}> + <ColumnHeader + icon='home' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> <StatusListContainer {...this.props} - scrollKey='home_timeline' + scrollKey={`home_timeline-${columnId}`} type='home' emptyMessage={emptyMessage} /> diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -28,41 +28,39 @@ class ColumnSettings extends React.PureComponent { const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; return ( - <ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}> - <div className='column-settings__outer'> - <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> + <div> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> - <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> - <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> + </div> - <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> - <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> - <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> + </div> - <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> - <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> - <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> + </div> - <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> + <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> - <div className='column-settings__row'> - <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> - <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> - <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> - </div> + <div className='column-settings__row'> + <SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> + <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> + <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> </div> - </ColumnCollapsable> + </div> ); } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js @@ -2,8 +2,10 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import Column from '../ui/components/column'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import NotificationContainer from './containers/notification_container'; import { ScrollContainer } from 'react-router-scroll'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; @@ -34,12 +36,14 @@ const mapStateToProps = state => ({ class Notifications extends React.PureComponent { static propTypes = { + columnId: PropTypes.string, notifications: ImmutablePropTypes.list.isRequired, dispatch: PropTypes.func.isRequired, shouldUpdateScroll: PropTypes.func, intl: PropTypes.object.isRequired, isLoading: PropTypes.bool, isUnread: PropTypes.bool, + multiColumn: PropTypes.bool, }; static defaultProps = { @@ -81,12 +85,36 @@ class Notifications extends React.PureComponent { })); } + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('NOTIFICATIONS', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + setRef = (c) => { this.node = c; } + setColumnRef = c => { + this.column = c; + } + render () { - const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props; + const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; let loadMore = ''; let scrollableArea = ''; @@ -124,10 +152,21 @@ class Notifications extends React.PureComponent { this.scrollableArea = scrollableArea; return ( - <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> - <ColumnSettingsContainer /> - <ClearColumnButton onClick={this.handleClear} /> - <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}> + <Column ref={this.setColumnRef}> + <ColumnHeader + icon='bell' + active={isUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + > + <ColumnSettingsContainer /> + </ColumnHeader> + + <ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}> {scrollableArea} </ScrollContainer> </Column> diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js @@ -2,7 +2,8 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import StatusListContainer from '../ui/containers/status_list_container'; -import Column from '../ui/components/column'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; import { refreshTimeline, updateTimeline, @@ -10,6 +11,7 @@ import { connectTimeline, disconnectTimeline, } from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import createStream from '../../stream'; @@ -24,28 +26,47 @@ const mapStateToProps = state => ({ accessToken: state.getIn(['meta', 'access_token']), }); -let subscription; - class PublicTimeline extends React.PureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, streamingAPIBaseURL: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired, hasUnread: PropTypes.bool, }; + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('PUBLIC', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + componentDidMount () { const { dispatch, streamingAPIBaseURL, accessToken } = this.props; dispatch(refreshTimeline('public')); - if (typeof subscription !== 'undefined') { + if (typeof this._subscription !== 'undefined') { return; } - subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { + this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { connected () { dispatch(connectTimeline('public')); @@ -74,19 +95,39 @@ class PublicTimeline extends React.PureComponent { } componentWillUnmount () { - // if (typeof subscription !== 'undefined') { - // subscription.close(); - // subscription = null; - // } + if (typeof this._subscription !== 'undefined') { + this._subscription.close(); + this._subscription = null; + } + } + + setRef = c => { + this.column = c; } render () { - const { intl, hasUnread } = this.props; + const { intl, columnId, hasUnread, multiColumn } = this.props; + const pinned = !!columnId; return ( - <Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> - <ColumnBackButtonSlim /> - <StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> + <Column ref={this.setRef}> + <ColumnHeader + icon='globe' + active={hasUnread} + title={intl.formatMessage(messages.title)} + onPin={this.handlePin} + onMove={this.handleMove} + onClick={this.handleHeaderClick} + pinned={pinned} + multiColumn={multiColumn} + /> + + <StatusListContainer + {...this.props} + type='public' + scrollKey={`public_timeline-${columnId}`} + emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} + /> </Column> ); } diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js @@ -2,34 +2,7 @@ import React from 'react'; import ColumnHeader from './column_header'; import PropTypes from 'prop-types'; import { debounce } from 'lodash'; - -const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; - -const scrollTop = (node) => { - const startTime = Date.now(); - const offset = node.scrollTop; - const targetY = -offset; - const duration = 1000; - let interrupt = false; - - const step = () => { - const elapsed = Date.now() - startTime; - const percentage = elapsed / duration; - - if (percentage > 1 || interrupt) { - return; - } - - node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration); - requestAnimationFrame(step); - }; - - step(); - - return () => { - interrupt = true; - }; -}; +import scrollTop from '../../../scroll'; class Column extends React.PureComponent { @@ -43,9 +16,11 @@ class Column extends React.PureComponent { handleHeaderClick = () => { const scrollable = this.node.querySelector('.scrollable'); + if (!scrollable) { return; } + this._interruptScrollAnimation = scrollTop(scrollable); } diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -1,16 +1,51 @@ import React from 'react'; import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import HomeTimeline from '../../home_timeline'; +import Notifications from '../../notifications'; +import PublicTimeline from '../../public_timeline'; +import CommunityTimeline from '../../community_timeline'; +import HashtagTimeline from '../../hashtag_timeline'; +import Compose from '../../compose'; -class ColumnsArea extends React.PureComponent { +const componentMap = { + 'COMPOSE': Compose, + 'HOME': HomeTimeline, + 'NOTIFICATIONS': Notifications, + 'PUBLIC': PublicTimeline, + 'COMMUNITY': CommunityTimeline, + 'HASHTAG': HashtagTimeline, +}; + +class ColumnsArea extends ImmutablePureComponent { static propTypes = { + columns: ImmutablePropTypes.list.isRequired, + singleColumn: PropTypes.bool, children: PropTypes.node, }; render () { + const { columns, children, singleColumn } = this.props; + + if (singleColumn) { + return ( + <div className='columns-area'> + {children} + </div> + ); + } + return ( <div className='columns-area'> - {this.props.children} + {columns.map(column => { + const SpecificComponent = componentMap[column.get('id')]; + const params = column.get('params', null) === null ? null : column.get('params').toJS(); + return <SpecificComponent key={column.get('uuid')} columnId={column.get('uuid')} params={params} multiColumn />; + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} </div> ); } diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js @@ -0,0 +1,8 @@ +import { connect } from 'react-redux'; +import ColumnsArea from '../components/columns_area'; + +const mapStateToProps = state => ({ + columns: state.getIn(['settings', 'columns']), +}); + +export default connect(mapStateToProps)(ColumnsArea); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js @@ -1,13 +1,9 @@ import React from 'react'; -import ColumnsArea from './components/columns_area'; import NotificationsContainer from './containers/notifications_container'; import PropTypes from 'prop-types'; import LoadingBarContainer from './containers/loading_bar_container'; -import HomeTimeline from '../home_timeline'; -import Compose from '../compose'; import TabsBar from './components/tabs_bar'; import ModalContainer from './containers/modal_container'; -import Notifications from '../notifications'; import { connect } from 'react-redux'; import { isMobile } from '../../is_mobile'; import { debounce } from 'lodash'; @@ -15,6 +11,7 @@ import { uploadCompose } from '../../actions/compose'; import { refreshTimeline } from '../../actions/timelines'; import { refreshNotifications } from '../../actions/notifications'; import UploadArea from './components/upload_area'; +import ColumnsAreaContainer from './containers/columns_area_container'; const noOp = () => false; @@ -119,31 +116,10 @@ class UI extends React.PureComponent { const { width, draggingOver } = this.state; const { children } = this.props; - let mountedColumns; - - if (isMobile(width)) { - mountedColumns = ( - <ColumnsArea> - {children} - </ColumnsArea> - ); - } else { - mountedColumns = ( - <ColumnsArea> - <Compose withHeader={true} /> - <HomeTimeline shouldUpdateScroll={noOp} /> - <Notifications shouldUpdateScroll={noOp} /> - <div className="column__wrapper">{children}</div> - </ColumnsArea> - ); - } - return ( <div className='ui' ref={this.setRef}> <TabsBar /> - - {mountedColumns} - + <ColumnsAreaContainer singleColumn={isMobile(width)}>{children}</ColumnsAreaContainer> <NotificationsContainer /> <LoadingBarContainer className="loading-bar" /> <ModalContainer /> diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js @@ -1,10 +1,18 @@ import { SETTING_CHANGE } from '../actions/settings'; +import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns'; import { STORE_HYDRATE } from '../actions/store'; import Immutable from 'immutable'; +import uuid from '../uuid'; const initialState = Immutable.Map({ onboarded: false, + columns: Immutable.fromJS([ + { id: 'COMPOSE', uuid: uuid(), params: {} }, + { id: 'HOME', uuid: uuid(), params: {} }, + { id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, + ]), + home: Immutable.Map({ shows: Immutable.Map({ reblog: true, @@ -40,12 +48,31 @@ const initialState = Immutable.Map({ }), }); +const moveColumn = (state, uuid, direction) => { + const columns = state.get('columns'); + const index = columns.findIndex(item => item.get('uuid') === uuid); + const newIndex = index + direction; + + let newColumns; + + newColumns = columns.splice(index, 1); + newColumns = newColumns.splice(newIndex, 0, columns.get(index)); + + return state.set('columns', newColumns); +}; + export default function settings(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: return state.mergeDeep(action.state.get('settings')); case SETTING_CHANGE: return state.setIn(action.key, action.value); + case COLUMN_ADD: + return state.update('columns', list => list.push(Immutable.fromJS({ id: action.id, uuid: uuid(), params: action.params }))); + case COLUMN_REMOVE: + return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)); + case COLUMN_MOVE: + return moveColumn(state, action.uuid, action.direction); default: return state; } diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js @@ -0,0 +1,29 @@ +const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b; + +const scrollTop = (node) => { + const startTime = Date.now(); + const offset = node.scrollTop; + const targetY = -offset; + const duration = 1000; + let interrupt = false; + + const step = () => { + const elapsed = Date.now() - startTime; + const percentage = elapsed / duration; + + if (percentage > 1 || interrupt) { + return; + } + + node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration); + requestAnimationFrame(step); + }; + + step(); + + return () => { + interrupt = true; + }; +}; + +export default scrollTop; diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss @@ -1526,6 +1526,22 @@ } } +.column-header__back-button { + background: lighten($ui-base-color, 4%); + border: 0; + font-family: inherit; + color: $ui-highlight-color; + cursor: pointer; + flex: 0 0 auto; + font-size: 16px; + padding: 15px; + z-index: 3; + + &:hover { + text-decoration: underline; + } +} + .column-back-button__icon { display: inline-block; margin-right: 5px; @@ -2030,6 +2046,89 @@ button.icon-button.active i.fa-retweet { } } +.column-header__buttons { + position: absolute; + right: 0; + top: 0; + display: flex; +} + +.column-header__button { + background: lighten($ui-base-color, 4%); + border: 0; + color: $ui-primary-color; + cursor: pointer; + font-size: 16px; + padding: 15px; + + &:hover { + color: lighten($ui-primary-color, 7%); + } + + &.active { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + + &:hover { + color: $primary-text-color; + background: lighten($ui-base-color, 8%); + } + } +} + +.column-header__collapsible { + max-height: 70vh; + overflow: hidden; + overflow-y: auto; + color: $ui-primary-color; + transition: max-height 150ms ease-in-out, opacity 300ms linear; + opacity: 1; + + & > div { + background: lighten($ui-base-color, 8%); + padding: 15px; + } + + &.collapsed { + max-height: 0; + opacity: 0.5; + } + + &.animating { + overflow-y: hidden; + } +} + +.column-header__setting-btn { + &:hover { + color: lighten($ui-primary-color, 4%); + text-decoration: underline; + } +} + +.column-header__setting-arrows { + float: right; + + .column-header__setting-btn { + padding: 0 10px; + + &:last-child { + padding-right: 0; + } + } +} + +.text-btn { + display: inline-block; + padding: 0; + font-family: inherit; + font-size: inherit; + color: inherit; + border: 0; + background: transparent; + cursor: pointer; +} + .column-header__icon { display: inline-block; margin-right: 5px;