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
This commit is contained in:
parent
20b647020b
commit
8ee2eb5d2e
40
app/javascript/mastodon/actions/columns.js
Normal file
40
app/javascript/mastodon/actions/columns.js
Normal 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());
|
||||||
|
};
|
||||||
|
};
|
45
app/javascript/mastodon/components/column.js
Normal file
45
app/javascript/mastodon/components/column.js
Normal 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;
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 () {
|
||||||
|
|
138
app/javascript/mastodon/components/column_header.js
Normal file
138
app/javascript/mastodon/components/column_header.js
Normal 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;
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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' />
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,25 +24,23 @@ 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'>
|
||||||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} />
|
<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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</ColumnCollapsable>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<div className='column-settings__row'>
|
||||||
|
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}>
|
||||||
<ColumnSettingsContainer />
|
<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
|
<StatusListContainer
|
||||||
{...this.props}
|
{...this.props}
|
||||||
scrollKey='home_timeline'
|
scrollKey={`home_timeline-${columnId}`}
|
||||||
type='home'
|
type='home'
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -28,41 +28,39 @@ 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'>
|
||||||
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
|
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
|
||||||
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
|
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
|
||||||
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
|
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
</ColumnCollapsable>
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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}>
|
||||||
<ColumnSettingsContainer />
|
<ColumnHeader
|
||||||
<ClearColumnButton onClick={this.handleClear} />
|
icon='bell'
|
||||||
<ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}>
|
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}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div className='columns-area'>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='columns-area'>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
@ -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 />
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
29
app/javascript/mastodon/scroll.js
Normal file
29
app/javascript/mastodon/scroll.js
Normal 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;
|
|
@ -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;
|
||||||
|
|
Reference in a new issue