From 8ee2eb5d2e7bd3c601c0277f12d8ad0c5f84cc43 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sun, 4 Jun 2017 01:39:38 +0200 Subject: [PATCH] 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 --- app/javascript/mastodon/actions/columns.js | 40 +++++ app/javascript/mastodon/components/column.js | 45 ++++++ .../mastodon/components/column_back_button.js | 2 +- .../components/column_back_button_slim.js | 3 +- .../mastodon/components/column_header.js | 138 ++++++++++++++++++ .../features/community_timeline/index.js | 67 +++++++-- .../mastodon/features/compose/index.js | 6 +- .../features/getting_started/index.js | 45 ++++-- .../features/hashtag_timeline/index.js | 54 ++++++- .../components/column_settings.js | 26 ++-- .../mastodon/features/home_timeline/index.js | 50 ++++++- .../components/column_settings.js | 52 ++++--- .../mastodon/features/notifications/index.js | 51 ++++++- .../features/public_timeline/index.js | 67 +++++++-- .../mastodon/features/ui/components/column.js | 31 +--- .../features/ui/components/columns_area.js | 39 ++++- .../ui/containers/columns_area_container.js | 8 + app/javascript/mastodon/features/ui/index.js | 28 +--- app/javascript/mastodon/reducers/settings.js | 27 ++++ app/javascript/mastodon/scroll.js | 29 ++++ app/javascript/styles/components.scss | 99 +++++++++++++ 21 files changed, 754 insertions(+), 153 deletions(-) create mode 100644 app/javascript/mastodon/actions/columns.js create mode 100644 app/javascript/mastodon/components/column.js create mode 100644 app/javascript/mastodon/components/column_header.js create mode 100644 app/javascript/mastodon/features/ui/containers/columns_area_container.js create mode 100644 app/javascript/mastodon/scroll.js diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js new file mode 100644 index 0000000000..bcb0cdf983 --- /dev/null +++ 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 new file mode 100644 index 0000000000..157a89c0e2 --- /dev/null +++ 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 ( +
+ {children} +
+ ); + } + +} + +export default Column; diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index 9d2de40f59..6c61ca6d46 100644 --- 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 index 6f6bbc0b8e..2d3f1b57aa 100644 --- 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 new file mode 100644 index 0000000000..e6349a3997 --- /dev/null +++ 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 = ( +
+ {children} +
+ ); + } + + if (multiColumn && pinned) { + pinButton = ; + + moveButtons = ( +
+ + +
+ ); + } else if (multiColumn) { + pinButton = ; + + backButton = ( + + ); + } + + const collapsedContent = [ + extraContent, + ]; + + if (multiColumn) { + collapsedContent.push(moveButtons); + collapsedContent.push(pinButton); + } + + if (children || multiColumn) { + collapseButton = ; + } + + return ( +
+
+ + {title} + +
+ {backButton} + {collapseButton} +
+
+ +
+
+ {(!collapsed || animating) && collapsedContent} +
+
+
+ ); + } + +} + +export default ColumnHeader; diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js index a8d58d3fa1..6d25811410 100644 --- 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 ( - - - } /> + + + + } + /> ); } diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index f64e3b891b..62d991ed10 100644 --- 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 = (
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index f9619550cd..b7ae7743e5 100644 --- 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(); + } + + if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { + navItems.push(); + } + + if (!columns.find(item => item.get('id') === 'COMMUNITY')) { + navItems.push(); + } + + if (!columns.find(item => item.get('id') === 'PUBLIC')) { + navItems.push(); + } + } + + navItems = navItems.concat([ + , + ]); if (me.get('locked')) { - followRequests = ; + navItems.push(); } + navItems = navItems.concat([ + , + , + ]); + return (
- - - - {followRequests} - - + {navItems} diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js index d997634bab..7f7b3e075b 100644 --- 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 ( - - - } /> + + + + } + /> ); } diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js index 094053aa86..902c7a06cc 100644 --- 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 ( - -
- +
+ -
- } /> -
+
+ } /> +
-
- } /> -
+
+ } /> +
- + -
- -
+
+
- +
); } diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js index 8f03adb120..a0a62eaf63 100644 --- 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 ( - - + + + + diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 6c1720b9e6..5150a3902e 100644 --- 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 = ; return ( - -
- +
+ -
- - - -
+
+ + + +
- + -
- - - -
+
+ + + +
- + -
- - - -
+
+ + + +
- + -
- - - -
+
+ + +
- +
); } diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index a62dcd50dd..60d8fff95e 100644 --- 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 ( - - - - + + + + + + {scrollableArea} diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js index 228ee8804e..001632004a 100644 --- 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 ( - - - } /> + + + + } + /> ); } diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js index e8973f5955..fb3d35b988 100644 --- 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 index ccff417efc..6ed8bc20d6 100644 --- 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 ( +
+ {children} +
+ ); + } + return (
- {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 ; + })} + + {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
); } diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js new file mode 100644 index 0000000000..6420f0784c --- /dev/null +++ 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 index af124b1ee0..9452e7fcf6 100644 --- 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 = ( - - {children} - - ); - } else { - mountedColumns = ( - - - - -
{children}
-
- ); - } - return (
- - {mountedColumns} - + {children} diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index ababd49839..ad70806b17 100644 --- 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 new file mode 100644 index 0000000000..c089d37db8 --- /dev/null +++ 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 index a617052827..ddd7e4cedd 100644 --- 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;