* 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 mountedmaster
@@ -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()); | |||
}; | |||
}; |
@@ -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 = () => { | |||
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(); | |||
} | |||
@@ -9,7 +9,8 @@ class ColumnBackButtonSlim extends React.PureComponent { | |||
}; | |||
handleClick = () => { | |||
this.context.router.push('/'); | |||
if (window.history && window.history.length === 1) this.context.router.push('/'); | |||
else this.context.router.goBack(); | |||
} | |||
render () { | |||
@@ -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 PropTypes from 'prop-types'; | |||
import StatusListContainer from '../ui/containers/status_list_container'; | |||
import Column from '../ui/components/column'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { | |||
refreshTimeline, | |||
updateTimeline, | |||
@@ -10,6 +11,7 @@ import { | |||
connectTimeline, | |||
disconnectTimeline, | |||
} from '../../actions/timelines'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | |||
import createStream from '../../stream'; | |||
@@ -24,28 +26,47 @@ const mapStateToProps = state => ({ | |||
accessToken: state.getIn(['meta', 'access_token']), | |||
}); | |||
let subscription; | |||
class CommunityTimeline extends React.PureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
columnId: PropTypes.string, | |||
intl: PropTypes.object.isRequired, | |||
streamingAPIBaseURL: PropTypes.string.isRequired, | |||
accessToken: PropTypes.string.isRequired, | |||
hasUnread: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('COMMUNITY', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
componentDidMount () { | |||
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; | |||
dispatch(refreshTimeline('community')); | |||
if (typeof subscription !== 'undefined') { | |||
if (typeof this._subscription !== 'undefined') { | |||
return; | |||
} | |||
subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { | |||
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', { | |||
connected () { | |||
dispatch(connectTimeline('community')); | |||
@@ -74,19 +95,39 @@ class CommunityTimeline extends React.PureComponent { | |||
} | |||
componentWillUnmount () { | |||
// if (typeof subscription !== 'undefined') { | |||
// subscription.close(); | |||
// subscription = null; | |||
// } | |||
if (typeof this._subscription !== 'undefined') { | |||
this._subscription.close(); | |||
this._subscription = null; | |||
} | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
render () { | |||
const { intl, hasUnread } = this.props; | |||
const { intl, hasUnread, columnId, multiColumn } = this.props; | |||
const pinned = !!columnId; | |||
return ( | |||
<Column icon='users' active={hasUnread} heading={intl.formatMessage(messages.title)}> | |||
<ColumnBackButtonSlim /> | |||
<StatusListContainer {...this.props} scrollKey='community_timeline' type='community' emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} /> | |||
<Column ref={this.setRef}> | |||
<ColumnHeader | |||
icon='users' | |||
active={hasUnread} | |||
title={intl.formatMessage(messages.title)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
/> | |||
<StatusListContainer | |||
{...this.props} | |||
scrollKey={`community_timeline-${columnId}`} | |||
type='community' | |||
emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />} | |||
/> | |||
</Column> | |||
); | |||
} | |||
@@ -28,7 +28,7 @@ class Compose extends React.PureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
withHeader: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
showSearch: PropTypes.bool, | |||
intl: PropTypes.object.isRequired, | |||
}; | |||
@@ -42,11 +42,11 @@ class Compose extends React.PureComponent { | |||
} | |||
render () { | |||
const { withHeader, showSearch, intl } = this.props; | |||
const { multiColumn, showSearch, intl } = this.props; | |||
let header = ''; | |||
if (withHeader) { | |||
if (multiColumn) { | |||
header = ( | |||
<div className='drawer__header'> | |||
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link> | |||
@@ -11,6 +11,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
const messages = defineMessages({ | |||
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, | |||
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, | |||
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, | |||
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, | |||
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, | |||
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, | |||
@@ -26,6 +28,7 @@ const messages = defineMessages({ | |||
const mapStateToProps = state => ({ | |||
me: state.getIn(['accounts', state.getIn(['meta', 'me'])]), | |||
columns: state.getIn(['settings', 'columns']), | |||
}); | |||
class GettingStarted extends ImmutablePureComponent { | |||
@@ -33,27 +36,51 @@ class GettingStarted extends ImmutablePureComponent { | |||
static propTypes = { | |||
intl: PropTypes.object.isRequired, | |||
me: ImmutablePropTypes.map.isRequired, | |||
columns: ImmutablePropTypes.list, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
render () { | |||
const { intl, me } = this.props; | |||
const { intl, me, columns, multiColumn } = this.props; | |||
let followRequests = ''; | |||
let navItems = []; | |||
if (multiColumn) { | |||
if (!columns.find(item => item.get('id') === 'HOME')) { | |||
navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />); | |||
} | |||
if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { | |||
navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} to='/notifications' />); | |||
} | |||
if (!columns.find(item => item.get('id') === 'COMMUNITY')) { | |||
navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />); | |||
} | |||
if (!columns.find(item => item.get('id') === 'PUBLIC')) { | |||
navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />); | |||
} | |||
} | |||
navItems = navItems.concat([ | |||
<ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, | |||
]); | |||
if (me.get('locked')) { | |||
followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />; | |||
navItems.push(<ColumnLink key='5' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />); | |||
} | |||
navItems = navItems.concat([ | |||
<ColumnLink key='6' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />, | |||
<ColumnLink key='7' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />, | |||
]); | |||
return ( | |||
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile={true}> | |||
<div className='getting-started__wrapper'> | |||
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)}/> | |||
<ColumnLink icon='users' hideOnMobile={true} text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' /> | |||
<ColumnLink icon='globe' hideOnMobile={true} text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' /> | |||
<ColumnLink icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' /> | |||
{followRequests} | |||
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' /> | |||
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' /> | |||
{navItems} | |||
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)}/> | |||
<ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' /> | |||
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' /> | |||
@@ -2,12 +2,14 @@ import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import StatusListContainer from '../ui/containers/status_list_container'; | |||
import Column from '../ui/components/column'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { | |||
refreshTimeline, | |||
updateTimeline, | |||
deleteFromTimelines, | |||
} from '../../actions/timelines'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | |||
import { FormattedMessage } from 'react-intl'; | |||
import createStream from '../../stream'; | |||
@@ -22,12 +24,33 @@ class HashtagTimeline extends React.PureComponent { | |||
static propTypes = { | |||
params: PropTypes.object.isRequired, | |||
columnId: PropTypes.string, | |||
dispatch: PropTypes.func.isRequired, | |||
streamingAPIBaseURL: PropTypes.string.isRequired, | |||
accessToken: PropTypes.string.isRequired, | |||
hasUnread: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('HASHTAG', { id: this.props.params.id })); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
_subscribe (dispatch, id) { | |||
const { streamingAPIBaseURL, accessToken } = this.props; | |||
@@ -74,13 +97,34 @@ class HashtagTimeline extends React.PureComponent { | |||
this._unsubscribe(); | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
render () { | |||
const { id, hasUnread } = this.props.params; | |||
const { hasUnread, columnId, multiColumn } = this.props; | |||
const { id } = this.props.params; | |||
const pinned = !!columnId; | |||
return ( | |||
<Column icon='hashtag' active={hasUnread} heading={id}> | |||
<ColumnBackButtonSlim /> | |||
<StatusListContainer scrollKey='hashtag_timeline' type='tag' id={id} emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} /> | |||
<Column ref={this.setRef}> | |||
<ColumnHeader | |||
icon='hashtag' | |||
active={hasUnread} | |||
title={id} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
/> | |||
<StatusListContainer | |||
scrollKey={`hashtag_timeline-${columnId}`} | |||
type='tag' | |||
id={id} | |||
emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />} | |||
/> | |||
</Column> | |||
); | |||
} | |||
@@ -24,25 +24,23 @@ class ColumnSettings extends React.PureComponent { | |||
const { settings, onChange, onSave, intl } = this.props; | |||
return ( | |||
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={209} onCollapse={onSave}> | |||
<div className='column-settings__outer'> | |||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> | |||
<div> | |||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} /> | |||
</div> | |||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> | |||
<span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span> | |||
<div className='column-settings__row'> | |||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingText settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} /> | |||
</div> | |||
</ColumnCollapsable> | |||
</div> | |||
); | |||
} | |||
@@ -2,7 +2,9 @@ import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import StatusListContainer from '../ui/containers/status_list_container'; | |||
import Column from '../ui/components/column'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import ColumnSettingsContainer from './containers/column_settings_container'; | |||
import Link from 'react-router/lib/Link'; | |||
@@ -19,13 +21,40 @@ const mapStateToProps = state => ({ | |||
class HomeTimeline extends React.PureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
hasUnread: PropTypes.bool, | |||
hasFollows: PropTypes.bool, | |||
columnId: PropTypes.string, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('HOME', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
render () { | |||
const { intl, hasUnread, hasFollows } = this.props; | |||
const { intl, hasUnread, hasFollows, columnId, multiColumn } = this.props; | |||
const pinned = !!columnId; | |||
let emptyMessage; | |||
@@ -36,12 +65,23 @@ class HomeTimeline extends React.PureComponent { | |||
} | |||
return ( | |||
<Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}> | |||
<ColumnSettingsContainer /> | |||
<Column ref={this.setRef}> | |||
<ColumnHeader | |||
icon='home' | |||
active={hasUnread} | |||
title={intl.formatMessage(messages.title)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
> | |||
<ColumnSettingsContainer /> | |||
</ColumnHeader> | |||
<StatusListContainer | |||
{...this.props} | |||
scrollKey='home_timeline' | |||
scrollKey={`home_timeline-${columnId}`} | |||
type='home' | |||
emptyMessage={emptyMessage} | |||
/> | |||
@@ -28,41 +28,39 @@ class ColumnSettings extends React.PureComponent { | |||
const soundStr = <FormattedMessage id='notifications.column_settings.sound' defaultMessage='Play sound' />; | |||
return ( | |||
<ColumnCollapsable icon='sliders' title={intl.formatMessage(messages.settings)} fullHeight={616} onCollapse={onSave}> | |||
<div className='column-settings__outer'> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> | |||
<div> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> | |||
<span className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
<div className='column-settings__row'> | |||
<SettingToggle settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} /> | |||
<SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} /> | |||
<SettingToggle settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} /> | |||
</div> | |||
</ColumnCollapsable> | |||
</div> | |||
); | |||
} | |||
@@ -2,8 +2,10 @@ import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import Column from '../ui/components/column'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import NotificationContainer from './containers/notification_container'; | |||
import { ScrollContainer } from 'react-router-scroll'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
@@ -34,12 +36,14 @@ const mapStateToProps = state => ({ | |||
class Notifications extends React.PureComponent { | |||
static propTypes = { | |||
columnId: PropTypes.string, | |||
notifications: ImmutablePropTypes.list.isRequired, | |||
dispatch: PropTypes.func.isRequired, | |||
shouldUpdateScroll: PropTypes.func, | |||
intl: PropTypes.object.isRequired, | |||
isLoading: PropTypes.bool, | |||
isUnread: PropTypes.bool, | |||
multiColumn: PropTypes.bool, | |||
}; | |||
static defaultProps = { | |||
@@ -81,12 +85,36 @@ class Notifications extends React.PureComponent { | |||
})); | |||
} | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('NOTIFICATIONS', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
setRef = (c) => { | |||
this.node = c; | |||
} | |||
setColumnRef = c => { | |||
this.column = c; | |||
} | |||
render () { | |||
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props; | |||
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn } = this.props; | |||
const pinned = !!columnId; | |||
let loadMore = ''; | |||
let scrollableArea = ''; | |||
@@ -124,10 +152,21 @@ class Notifications extends React.PureComponent { | |||
this.scrollableArea = scrollableArea; | |||
return ( | |||
<Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}> | |||
<ColumnSettingsContainer /> | |||
<ClearColumnButton onClick={this.handleClear} /> | |||
<ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}> | |||
<Column ref={this.setColumnRef}> | |||
<ColumnHeader | |||
icon='bell' | |||
active={isUnread} | |||
title={intl.formatMessage(messages.title)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
> | |||
<ColumnSettingsContainer /> | |||
</ColumnHeader> | |||
<ScrollContainer scrollKey={`notifications-${columnId}`} shouldUpdateScroll={shouldUpdateScroll}> | |||
{scrollableArea} | |||
</ScrollContainer> | |||
</Column> | |||
@@ -2,7 +2,8 @@ import React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import PropTypes from 'prop-types'; | |||
import StatusListContainer from '../ui/containers/status_list_container'; | |||
import Column from '../ui/components/column'; | |||
import Column from '../../components/column'; | |||
import ColumnHeader from '../../components/column_header'; | |||
import { | |||
refreshTimeline, | |||
updateTimeline, | |||
@@ -10,6 +11,7 @@ import { | |||
connectTimeline, | |||
disconnectTimeline, | |||
} from '../../actions/timelines'; | |||
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | |||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | |||
import ColumnBackButtonSlim from '../../components/column_back_button_slim'; | |||
import createStream from '../../stream'; | |||
@@ -24,28 +26,47 @@ const mapStateToProps = state => ({ | |||
accessToken: state.getIn(['meta', 'access_token']), | |||
}); | |||
let subscription; | |||
class PublicTimeline extends React.PureComponent { | |||
static propTypes = { | |||
dispatch: PropTypes.func.isRequired, | |||
intl: PropTypes.object.isRequired, | |||
columnId: PropTypes.string, | |||
multiColumn: PropTypes.bool, | |||
streamingAPIBaseURL: PropTypes.string.isRequired, | |||
accessToken: PropTypes.string.isRequired, | |||
hasUnread: PropTypes.bool, | |||
}; | |||
handlePin = () => { | |||
const { columnId, dispatch } = this.props; | |||
if (columnId) { | |||
dispatch(removeColumn(columnId)); | |||
} else { | |||
dispatch(addColumn('PUBLIC', {})); | |||
} | |||
} | |||
handleMove = (dir) => { | |||
const { columnId, dispatch } = this.props; | |||
dispatch(moveColumn(columnId, dir)); | |||
} | |||
handleHeaderClick = () => { | |||
this.column.scrollTop(); | |||
} | |||
componentDidMount () { | |||
const { dispatch, streamingAPIBaseURL, accessToken } = this.props; | |||
dispatch(refreshTimeline('public')); | |||
if (typeof subscription !== 'undefined') { | |||
if (typeof this._subscription !== 'undefined') { | |||
return; | |||
} | |||
subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { | |||
this._subscription = createStream(streamingAPIBaseURL, accessToken, 'public', { | |||
connected () { | |||
dispatch(connectTimeline('public')); | |||
@@ -74,19 +95,39 @@ class PublicTimeline extends React.PureComponent { | |||
} | |||
componentWillUnmount () { | |||
// if (typeof subscription !== 'undefined') { | |||
// subscription.close(); | |||
// subscription = null; | |||
// } | |||
if (typeof this._subscription !== 'undefined') { | |||
this._subscription.close(); | |||
this._subscription = null; | |||
} | |||
} | |||
setRef = c => { | |||
this.column = c; | |||
} | |||
render () { | |||
const { intl, hasUnread } = this.props; | |||
const { intl, columnId, hasUnread, multiColumn } = this.props; | |||
const pinned = !!columnId; | |||
return ( | |||
<Column icon='globe' active={hasUnread} heading={intl.formatMessage(messages.title)}> | |||
<ColumnBackButtonSlim /> | |||
<StatusListContainer {...this.props} type='public' scrollKey='public_timeline' emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} /> | |||
<Column ref={this.setRef}> | |||
<ColumnHeader | |||
icon='globe' | |||
active={hasUnread} | |||
title={intl.formatMessage(messages.title)} | |||
onPin={this.handlePin} | |||
onMove={this.handleMove} | |||
onClick={this.handleHeaderClick} | |||
pinned={pinned} | |||
multiColumn={multiColumn} | |||
/> | |||
<StatusListContainer | |||
{...this.props} | |||
type='public' | |||
scrollKey={`public_timeline-${columnId}`} | |||
emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />} | |||
/> | |||
</Column> | |||
); | |||
} | |||
@@ -2,34 +2,7 @@ import React from 'react'; | |||
import ColumnHeader from './column_header'; | |||
import PropTypes from 'prop-types'; | |||
import { debounce } from 'lodash'; | |||
const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b; | |||
const scrollTop = (node) => { | |||
const startTime = Date.now(); | |||
const offset = node.scrollTop; | |||
const targetY = -offset; | |||
const duration = 1000; | |||
let interrupt = false; | |||
const step = () => { | |||
const elapsed = Date.now() - startTime; | |||
const percentage = elapsed / duration; | |||
if (percentage > 1 || interrupt) { | |||
return; | |||
} | |||
node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration); | |||
requestAnimationFrame(step); | |||
}; | |||
step(); | |||
return () => { | |||
interrupt = true; | |||
}; | |||
}; | |||
import scrollTop from '../../../scroll'; | |||
class Column extends React.PureComponent { | |||
@@ -43,9 +16,11 @@ class Column extends React.PureComponent { | |||
handleHeaderClick = () => { | |||
const scrollable = this.node.querySelector('.scrollable'); | |||
if (!scrollable) { | |||
return; | |||
} | |||
this._interruptScrollAnimation = scrollTop(scrollable); | |||
} | |||
@@ -1,16 +1,51 @@ | |||
import React from 'react'; | |||
import PropTypes from 'prop-types'; | |||
import ImmutablePropTypes from 'react-immutable-proptypes'; | |||
import ImmutablePureComponent from 'react-immutable-pure-component'; | |||
import HomeTimeline from '../../home_timeline'; | |||
import Notifications from '../../notifications'; | |||
import PublicTimeline from '../../public_timeline'; | |||
import CommunityTimeline from '../../community_timeline'; | |||
import HashtagTimeline from '../../hashtag_timeline'; | |||
import Compose from '../../compose'; | |||
class ColumnsArea extends React.PureComponent { | |||
const componentMap = { | |||
'COMPOSE': Compose, | |||
'HOME': HomeTimeline, | |||
'NOTIFICATIONS': Notifications, | |||
'PUBLIC': PublicTimeline, | |||
'COMMUNITY': CommunityTimeline, | |||
'HASHTAG': HashtagTimeline, | |||
}; | |||
class ColumnsArea extends ImmutablePureComponent { | |||
static propTypes = { | |||
columns: ImmutablePropTypes.list.isRequired, | |||
singleColumn: PropTypes.bool, | |||
children: PropTypes.node, | |||
}; | |||
render () { | |||
const { columns, children, singleColumn } = this.props; | |||
if (singleColumn) { | |||
return ( | |||
<div className='columns-area'> | |||
{children} | |||
</div> | |||
); | |||
} | |||
return ( | |||
<div className='columns-area'> | |||
{this.props.children} | |||
{columns.map(column => { | |||
const SpecificComponent = componentMap[column.get('id')]; | |||
const params = column.get('params', null) === null ? null : column.get('params').toJS(); | |||
return <SpecificComponent key={column.get('uuid')} columnId={column.get('uuid')} params={params} multiColumn />; | |||
})} | |||
{React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))} | |||
</div> | |||
); | |||
} | |||
@@ -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 ColumnsArea from './components/columns_area'; | |||
import NotificationsContainer from './containers/notifications_container'; | |||
import PropTypes from 'prop-types'; | |||
import LoadingBarContainer from './containers/loading_bar_container'; | |||
import HomeTimeline from '../home_timeline'; | |||
import Compose from '../compose'; | |||
import TabsBar from './components/tabs_bar'; | |||
import ModalContainer from './containers/modal_container'; | |||
import Notifications from '../notifications'; | |||
import { connect } from 'react-redux'; | |||
import { isMobile } from '../../is_mobile'; | |||
import { debounce } from 'lodash'; | |||
@@ -15,6 +11,7 @@ import { uploadCompose } from '../../actions/compose'; | |||
import { refreshTimeline } from '../../actions/timelines'; | |||
import { refreshNotifications } from '../../actions/notifications'; | |||
import UploadArea from './components/upload_area'; | |||
import ColumnsAreaContainer from './containers/columns_area_container'; | |||
const noOp = () => false; | |||
@@ -119,31 +116,10 @@ class UI extends React.PureComponent { | |||
const { width, draggingOver } = this.state; | |||
const { children } = this.props; | |||
let mountedColumns; | |||
if (isMobile(width)) { | |||
mountedColumns = ( | |||
<ColumnsArea> | |||
{children} | |||
</ColumnsArea> | |||
); | |||
} else { | |||
mountedColumns = ( | |||
<ColumnsArea> | |||
<Compose withHeader={true} /> | |||
<HomeTimeline shouldUpdateScroll={noOp} /> | |||
<Notifications shouldUpdateScroll={noOp} /> | |||
<div className="column__wrapper">{children}</div> | |||
</ColumnsArea> | |||
); | |||
} | |||
return ( | |||
<div className='ui' ref={this.setRef}> | |||
<TabsBar /> | |||
{mountedColumns} | |||
<ColumnsAreaContainer singleColumn={isMobile(width)}>{children}</ColumnsAreaContainer> | |||
<NotificationsContainer /> | |||
<LoadingBarContainer className="loading-bar" /> | |||
<ModalContainer /> | |||
@@ -1,10 +1,18 @@ | |||
import { SETTING_CHANGE } from '../actions/settings'; | |||
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from '../actions/columns'; | |||
import { STORE_HYDRATE } from '../actions/store'; | |||
import Immutable from 'immutable'; | |||
import uuid from '../uuid'; | |||
const initialState = Immutable.Map({ | |||
onboarded: false, | |||
columns: Immutable.fromJS([ | |||
{ id: 'COMPOSE', uuid: uuid(), params: {} }, | |||
{ id: 'HOME', uuid: uuid(), params: {} }, | |||
{ id: 'NOTIFICATIONS', uuid: uuid(), params: {} }, | |||
]), | |||
home: Immutable.Map({ | |||
shows: Immutable.Map({ | |||
reblog: true, | |||
@@ -40,12 +48,31 @@ const initialState = Immutable.Map({ | |||
}), | |||
}); | |||
const moveColumn = (state, uuid, direction) => { | |||
const columns = state.get('columns'); | |||
const index = columns.findIndex(item => item.get('uuid') === uuid); | |||
const newIndex = index + direction; | |||
let newColumns; | |||
newColumns = columns.splice(index, 1); | |||
newColumns = newColumns.splice(newIndex, 0, columns.get(index)); | |||
return state.set('columns', newColumns); | |||
}; | |||
export default function settings(state = initialState, action) { | |||
switch(action.type) { | |||
case STORE_HYDRATE: | |||
return state.mergeDeep(action.state.get('settings')); | |||
case SETTING_CHANGE: | |||
return state.setIn(action.key, action.value); | |||
case COLUMN_ADD: | |||
return state.update('columns', list => list.push(Immutable.fromJS({ id: action.id, uuid: uuid(), params: action.params }))); | |||
case COLUMN_REMOVE: | |||
return state.update('columns', list => list.filterNot(item => item.get('uuid') === action.uuid)); | |||
case COLUMN_MOVE: | |||
return moveColumn(state, action.uuid, action.direction); | |||
default: | |||
return state; | |||
} | |||
@@ -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 { | |||
display: inline-block; | |||
margin-right: 5px; | |||
@@ -2030,6 +2046,89 @@ button.icon-button.active i.fa-retweet { | |||
} | |||
} | |||
.column-header__buttons { | |||
position: absolute; | |||
right: 0; | |||
top: 0; | |||
display: flex; | |||
} | |||
.column-header__button { | |||
background: lighten($ui-base-color, 4%); | |||
border: 0; | |||
color: $ui-primary-color; | |||
cursor: pointer; | |||
font-size: 16px; | |||
padding: 15px; | |||
&:hover { | |||
color: lighten($ui-primary-color, 7%); | |||
} | |||
&.active { | |||
color: $primary-text-color; | |||
background: lighten($ui-base-color, 8%); | |||
&:hover { | |||
color: $primary-text-color; | |||
background: lighten($ui-base-color, 8%); | |||
} | |||
} | |||
} | |||
.column-header__collapsible { | |||
max-height: 70vh; | |||
overflow: hidden; | |||
overflow-y: auto; | |||
color: $ui-primary-color; | |||
transition: max-height 150ms ease-in-out, opacity 300ms linear; | |||
opacity: 1; | |||
& > div { | |||
background: lighten($ui-base-color, 8%); | |||
padding: 15px; | |||
} | |||
&.collapsed { | |||
max-height: 0; | |||
opacity: 0.5; | |||
} | |||
&.animating { | |||
overflow-y: hidden; | |||
} | |||
} | |||
.column-header__setting-btn { | |||
&:hover { | |||
color: lighten($ui-primary-color, 4%); | |||
text-decoration: underline; | |||
} | |||
} | |||
.column-header__setting-arrows { | |||
float: right; | |||
.column-header__setting-btn { | |||
padding: 0 10px; | |||
&:last-child { | |||
padding-right: 0; | |||
} | |||
} | |||
} | |||
.text-btn { | |||
display: inline-block; | |||
padding: 0; | |||
font-family: inherit; | |||
font-size: inherit; | |||
color: inherit; | |||
border: 0; | |||
background: transparent; | |||
cursor: pointer; | |||
} | |||
.column-header__icon { | |||
display: inline-block; | |||
margin-right: 5px; | |||