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 = () => { | ||||
|     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 () { | ||||
|  | ||||
							
								
								
									
										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 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', '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 className='column-settings__row'> | ||||
|           <SettingToggle settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_reblogs' defaultMessage='Show boosts' />} /> | ||||
|         </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 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> | ||||
| 
 | ||||
|           <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 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> | ||||
|       </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 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; | ||||
|   } | ||||
|  | ||||
							
								
								
									
										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 { | ||||
|   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; | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user