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:
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;