Skip to content

Commit

Permalink
Allow mounting arbitrary columns (mastodon#3207)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
Gargron authored and koteitan committed Jun 25, 2017
1 parent 738471c commit bdfd64a
Show file tree
Hide file tree
Showing 21 changed files with 754 additions and 153 deletions.
40 changes: 40 additions & 0 deletions app/javascript/mastodon/actions/columns.js
Original file line number Diff line number Diff line change
@@ -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());
};
};
45 changes: 45 additions & 0 deletions app/javascript/mastodon/components/column.js
Original file line number Diff line number Diff line change
@@ -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;
2 changes: 1 addition & 1 deletion app/javascript/mastodon/components/column_back_button.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
138 changes: 138 additions & 0 deletions app/javascript/mastodon/components/column_header.js
Original file line number Diff line number Diff line change
@@ -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;
67 changes: 54 additions & 13 deletions app/javascript/mastodon/features/community_timeline/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@ 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,
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';
Expand All @@ -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'));
Expand Down Expand Up @@ -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>
);
}
Expand Down
6 changes: 3 additions & 3 deletions app/javascript/mastodon/features/compose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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>
Expand Down
Loading

0 comments on commit bdfd64a

Please sign in to comment.