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
signup-info-prompt
Eugen Rochko 2017-06-04 01:39:38 +02:00 committed by GitHub
parent 20b647020b
commit 8ee2eb5d2e
21 changed files with 763 additions and 162 deletions

View File

@ -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());
};
};

View File

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

View File

@ -9,7 +9,7 @@ class ColumnBackButton extends React.PureComponent {
}; };
handleClick = () => { 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(); else this.context.router.goBack();
} }

View File

@ -9,7 +9,8 @@ class ColumnBackButtonSlim extends React.PureComponent {
}; };
handleClick = () => { handleClick = () => {
this.context.router.push('/'); if (window.history && window.history.length === 1) this.context.router.push('/');
else this.context.router.goBack();
} }
render () { render () {

View File

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

View File

@ -2,7 +2,8 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container'; 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 { import {
refreshTimeline, refreshTimeline,
updateTimeline, updateTimeline,
@ -10,6 +11,7 @@ import {
connectTimeline, connectTimeline,
disconnectTimeline, disconnectTimeline,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import createStream from '../../stream'; import createStream from '../../stream';
@ -24,28 +26,47 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token']), accessToken: state.getIn(['meta', 'access_token']),
}); });
let subscription;
class CommunityTimeline extends React.PureComponent { class CommunityTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired, streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, 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 () { componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('community')); dispatch(refreshTimeline('community'));
if (typeof subscription !== 'undefined') { if (typeof this._subscription !== 'undefined') {
return; return;
} }
subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
connected () { connected () {
dispatch(connectTimeline('community')); dispatch(connectTimeline('community'));
@ -74,19 +95,39 @@ class CommunityTimeline extends React.PureComponent {
} }
componentWillUnmount () { componentWillUnmount () {
// if (typeof subscription !== 'undefined') { if (typeof this._subscription !== 'undefined') {
// subscription.close(); this._subscription.close();
// subscription = null; this._subscription = null;
// } }
}
setRef = c => {
this.column = c;
} }
render () { render () {
const { intl, hasUnread } = this.props; const { intl, hasUnread, columnId, multiColumn } = this.props;
const pinned = !!columnId;
return ( return (
<Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> <Column ref={this.setRef}>
<ColumnBackButtonSlim /> <ColumnHeader
<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!' />} /> 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> </Column>
); );
} }

View File

@ -28,7 +28,7 @@ class Compose extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
withHeader: PropTypes.bool, multiColumn: PropTypes.bool,
showSearch: PropTypes.bool, showSearch: PropTypes.bool,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
@ -42,11 +42,11 @@ class Compose extends React.PureComponent {
} }
render () { render () {
const { withHeader, showSearch, intl } = this.props; const { multiColumn, showSearch, intl } = this.props;
let header = ''; let header = '';
if (withHeader) { if (multiColumn) {
header = ( header = (
<div className='drawer__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> <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>

View File

@ -11,6 +11,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, 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' }, public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
@ -26,6 +28,7 @@ const messages = defineMessages({
const mapStateToProps = state => ({ const mapStateToProps = state => ({
me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
columns: state.getIn(['settings', 'columns']),
}); });
class GettingStarted extends ImmutablePureComponent { class GettingStarted extends ImmutablePureComponent {
@ -33,27 +36,51 @@ class GettingStarted extends ImmutablePureComponent {
static propTypes = { static propTypes = {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
me: ImmutablePropTypes.map.isRequired, me: ImmutablePropTypes.map.isRequired,
columns: ImmutablePropTypes.list,
multiColumn: PropTypes.bool,
}; };
render () { 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')) { 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 ( return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}> <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}>
<div className='getting-started__wrapper'> <div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/> <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/>
<ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> {navItems}
<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' />
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/> <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/>
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />

View File

@ -2,12 +2,14 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container'; 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 { import {
refreshTimeline, refreshTimeline,
updateTimeline, updateTimeline,
deleteFromTimelines, deleteFromTimelines,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import createStream from '../../stream'; import createStream from '../../stream';
@ -22,12 +24,33 @@ class HashtagTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
columnId: PropTypes.string,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
streamingAPIBaseURL: PropTypes.string.isRequired, streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, 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) { _subscribe (dispatch, id) {
const { streamingAPIBaseURL, accessToken } = this.props; const { streamingAPIBaseURL, accessToken } = this.props;
@ -74,13 +97,34 @@ class HashtagTimeline extends React.PureComponent {
this._unsubscribe(); this._unsubscribe();
} }
setRef = c => {
this.column = c;
}
render () { render () {
const { id, hasUnread } = this.props.params; const { hasUnread, columnId, multiColumn } = this.props;
const { id } = this.props.params;
const pinned = !!columnId;
return ( return (
<Column icon='hashtag' active={hasUnread} heading={id}> <Column ref={this.setRef}>
<ColumnBackButtonSlim /> <ColumnHeader
<StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> 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> </Column>
); );
} }

View File

@ -24,8 +24,7 @@ class ColumnSettings extends React.PureComponent {
const { settings, onChange, onSave, intl } = this.props; const { settings, onChange, onSave, intl } = this.props;
return ( return (
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}> <div>
<div className='column-settings__outer'>
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
@ -42,7 +41,6 @@ class ColumnSettings extends React.PureComponent {
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> <SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
</div> </div>
</div> </div>
</ColumnCollapsable>
); );
} }

View File

@ -2,7 +2,9 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container'; 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 { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container'; import ColumnSettingsContainer from './containers/column_settings_container';
import Link from 'react-router/lib/Link'; import Link from 'react-router/lib/Link';
@ -19,13 +21,40 @@ const mapStateToProps = state => ({
class HomeTimeline extends React.PureComponent { class HomeTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool, hasUnread: PropTypes.bool,
hasFollows: 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 () { render () {
const { intl, hasUnread, hasFollows } = this.props; const { intl, hasUnread, hasFollows, columnId, multiColumn } = this.props;
const pinned = !!columnId;
let emptyMessage; let emptyMessage;
@ -36,12 +65,23 @@ class HomeTimeline extends React.PureComponent {
} }
return ( return (
<Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> <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 /> <ColumnSettingsContainer />
</ColumnHeader>
<StatusListContainer <StatusListContainer
{...this.props} {...this.props}
scrollKey='home_timeline' scrollKey={`home_timeline-${columnId}`}
type='home' type='home'
emptyMessage={emptyMessage} emptyMessage={emptyMessage}
/> />

View File

@ -28,8 +28,7 @@ class ColumnSettings extends React.PureComponent {
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />;
return ( return (
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}> <div>
<div className='column-settings__outer'>
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> <span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
<div className='column-settings__row'> <div className='column-settings__row'>
@ -62,7 +61,6 @@ class ColumnSettings extends React.PureComponent {
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> <SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
</div> </div>
</div> </div>
</ColumnCollapsable>
); );
} }

View File

@ -2,8 +2,10 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; 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 { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
import { ScrollContainer } from 'react-router-scroll'; import { ScrollContainer } from 'react-router-scroll';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -34,12 +36,14 @@ const mapStateToProps = state => ({
class Notifications extends React.PureComponent { class Notifications extends React.PureComponent {
static propTypes = { static propTypes = {
columnId: PropTypes.string,
notifications: ImmutablePropTypes.list.isRequired, notifications: ImmutablePropTypes.list.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func, shouldUpdateScroll: PropTypes.func,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
isUnread: PropTypes.bool, isUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
}; };
static defaultProps = { 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) => { setRef = (c) => {
this.node = c; this.node = c;
} }
setColumnRef = c => {
this.column = c;
}
render () { 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 loadMore = '';
let scrollableArea = ''; let scrollableArea = '';
@ -124,10 +152,21 @@ class Notifications extends React.PureComponent {
this.scrollableArea = scrollableArea; this.scrollableArea = scrollableArea;
return ( return (
<Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> <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 /> <ColumnSettingsContainer />
<ClearColumnButton onClick={this.handleClear} /> </ColumnHeader>
<ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}>
<ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}>
{scrollableArea} {scrollableArea}
</ScrollContainer> </ScrollContainer>
</Column> </Column>

View File

@ -2,7 +2,8 @@ import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container'; 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 { import {
refreshTimeline, refreshTimeline,
updateTimeline, updateTimeline,
@ -10,6 +11,7 @@ import {
connectTimeline, connectTimeline,
disconnectTimeline, disconnectTimeline,
} from '../../actions/timelines'; } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import ColumnBackButtonSlim from '../../components/column_back_button_slim';
import createStream from '../../stream'; import createStream from '../../stream';
@ -24,28 +26,47 @@ const mapStateToProps = state => ({
accessToken: state.getIn(['meta', 'access_token']), accessToken: state.getIn(['meta', 'access_token']),
}); });
let subscription;
class PublicTimeline extends React.PureComponent { class PublicTimeline extends React.PureComponent {
static propTypes = { static propTypes = {
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
multiColumn: PropTypes.bool,
streamingAPIBaseURL: PropTypes.string.isRequired, streamingAPIBaseURL: PropTypes.string.isRequired,
accessToken: PropTypes.string.isRequired, accessToken: PropTypes.string.isRequired,
hasUnread: PropTypes.bool, 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 () { componentDidMount () {
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
dispatch(refreshTimeline('public')); dispatch(refreshTimeline('public'));
if (typeof subscription !== 'undefined') { if (typeof this._subscription !== 'undefined') {
return; return;
} }
subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
connected () { connected () {
dispatch(connectTimeline('public')); dispatch(connectTimeline('public'));
@ -74,19 +95,39 @@ class PublicTimeline extends React.PureComponent {
} }
componentWillUnmount () { componentWillUnmount () {
// if (typeof subscription !== 'undefined') { if (typeof this._subscription !== 'undefined') {
// subscription.close(); this._subscription.close();
// subscription = null; this._subscription = null;
// } }
}
setRef = c => {
this.column = c;
} }
render () { render () {
const { intl, hasUnread } = this.props; const { intl, columnId, hasUnread, multiColumn } = this.props;
const pinned = !!columnId;
return ( return (
<Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> <Column ref={this.setRef}>
<ColumnBackButtonSlim /> <ColumnHeader
<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' />} /> 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> </Column>
); );
} }

View File

@ -2,34 +2,7 @@ import React from 'react';
import ColumnHeader from './column_header'; import ColumnHeader from './column_header';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import scrollTop from '../../../scroll';
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;
};
};
class Column extends React.PureComponent { class Column extends React.PureComponent {
@ -43,9 +16,11 @@ class Column extends React.PureComponent {
handleHeaderClick = () => { handleHeaderClick = () => {
const scrollable = this.node.querySelector('.scrollable'); const scrollable = this.node.querySelector('.scrollable');
if (!scrollable) { if (!scrollable) {
return; return;
} }
this._interruptScrollAnimation = scrollTop(scrollable); this._interruptScrollAnimation = scrollTop(scrollable);
} }

View File

@ -1,16 +1,51 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; 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 = { static propTypes = {
columns: ImmutablePropTypes.list.isRequired,
singleColumn: PropTypes.bool,
children: PropTypes.node, children: PropTypes.node,
}; };
render () { render () {
const { columns, children, singleColumn } = this.props;
if (singleColumn) {
return ( return (
<div className='columns-area'> <div className='columns-area'>
{this.props.children} {children}
</div>
);
}
return (
<div className='columns-area'>
{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> </div>
); );
} }

View File

@ -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);

View File

@ -1,13 +1,9 @@
import React from 'react'; import React from 'react';
import ColumnsArea from './components/columns_area';
import NotificationsContainer from './containers/notifications_container'; import NotificationsContainer from './containers/notifications_container';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import LoadingBarContainer from './containers/loading_bar_container'; import LoadingBarContainer from './containers/loading_bar_container';
import HomeTimeline from '../home_timeline';
import Compose from '../compose';
import TabsBar from './components/tabs_bar'; import TabsBar from './components/tabs_bar';
import ModalContainer from './containers/modal_container'; import ModalContainer from './containers/modal_container';
import Notifications from '../notifications';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { isMobile } from '../../is_mobile'; import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
@ -15,6 +11,7 @@ import { uploadCompose } from '../../actions/compose';
import { refreshTimeline } from '../../actions/timelines'; import { refreshTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications'; import { refreshNotifications } from '../../actions/notifications';
import UploadArea from './components/upload_area'; import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container';
const noOp = () => false; const noOp = () => false;
@ -119,31 +116,10 @@ class UI extends React.PureComponent {
const { width, draggingOver } = this.state; const { width, draggingOver } = this.state;
const { children } = this.props; 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 ( return (
<div className='ui' ref={this.setRef}> <div className='ui' ref={this.setRef}>
<TabsBar /> <TabsBar />
<ColumnsAreaContainer singleColumn={isMobile(width)}>{children}</ColumnsAreaContainer>
{mountedColumns}
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer className="loading-bar" /> <LoadingBarContainer className="loading-bar" />
<ModalContainer /> <ModalContainer />

View File

@ -1,10 +1,18 @@
import { SETTING_CHANGE } from '../actions/settings'; import { SETTING_CHANGE } from '../actions/settings';
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns';
import { STORE_HYDRATE } from '../actions/store'; import { STORE_HYDRATE } from '../actions/store';
import Immutable from 'immutable'; import Immutable from 'immutable';
import uuid from '../uuid';
const initialState = Immutable.Map({ const initialState = Immutable.Map({
onboarded: false, onboarded: false,
columns: Immutable.fromJS([
{ id: 'COMPOSE', uuid: uuid(), params: {} },
{ id: 'HOME', uuid: uuid(), params: {} },
{ id: 'NOTIFICATIONS', uuid: uuid(), params: {} },
]),
home: Immutable.Map({ home: Immutable.Map({
shows: Immutable.Map({ shows: Immutable.Map({
reblog: true, 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) { export default function settings(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: case STORE_HYDRATE:
return state.mergeDeep(action.state.get('settings')); return state.mergeDeep(action.state.get('settings'));
case SETTING_CHANGE: case SETTING_CHANGE:
return state.setIn(action.key, action.value); 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: default:
return state; return state;
} }

View File

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

View File

@ -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 { .column-back-button__icon {
display: inline-block; display: inline-block;
margin-right: 5px; 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 { .column-header__icon {
display: inline-block; display: inline-block;
margin-right: 5px; margin-right: 5px;