about summary refs log tree commit diff
path: root/app/javascript/mastodon
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-06-04 01:39:38 +0200
committerGitHub <noreply@github.com>2017-06-04 01:39:38 +0200
commit8ee2eb5d2e7bd3c601c0277f12d8ad0c5f84cc43 (patch)
tree2c51c5c5cd47273cf1b66d553e1cc5c7f762a0f8 /app/javascript/mastodon
parent20b647020bf8de2af6d2ce44ed76566d137dd1f6 (diff)
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
Diffstat (limited to 'app/javascript/mastodon')
-rw-r--r--app/javascript/mastodon/actions/columns.js40
-rw-r--r--app/javascript/mastodon/components/column.js45
-rw-r--r--app/javascript/mastodon/components/column_back_button.js2
-rw-r--r--app/javascript/mastodon/components/column_back_button_slim.js3
-rw-r--r--app/javascript/mastodon/components/column_header.js138
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js67
-rw-r--r--app/javascript/mastodon/features/compose/index.js6
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js45
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js54
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/column_settings.js26
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js50
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js52
-rw-r--r--app/javascript/mastodon/features/notifications/index.js51
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js67
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js31
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js39
-rw-r--r--app/javascript/mastodon/features/ui/containers/columns_area_container.js8
-rw-r--r--app/javascript/mastodon/features/ui/index.js28
-rw-r--r--app/javascript/mastodon/reducers/settings.js27
-rw-r--r--app/javascript/mastodon/scroll.js29
20 files changed, 655 insertions, 153 deletions
diff --git a/app/javascript/mastodon/actions/columns.js b/app/javascript/mastodon/actions/columns.js
new file mode 100644
index 000000000..bcb0cdf98
--- /dev/null
+++ b/app/javascript/mastodon/actions/columns.js
@@ -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());
+  };
+};
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
new file mode 100644
index 000000000..157a89c0e
--- /dev/null
+++ b/app/javascript/mastodon/components/column.js
@@ -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;
diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js
index 9d2de40f5..6c61ca6d4 100644
--- a/app/javascript/mastodon/components/column_back_button.js
+++ b/app/javascript/mastodon/components/column_back_button.js
@@ -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();
   }
 
diff --git a/app/javascript/mastodon/components/column_back_button_slim.js b/app/javascript/mastodon/components/column_back_button_slim.js
index 6f6bbc0b8..2d3f1b57a 100644
--- a/app/javascript/mastodon/components/column_back_button_slim.js
+++ b/app/javascript/mastodon/components/column_back_button_slim.js
@@ -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 () {
diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js
new file mode 100644
index 000000000..e6349a399
--- /dev/null
+++ b/app/javascript/mastodon/components/column_header.js
@@ -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;
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
index a8d58d3fa..6d2581141 100644
--- a/app/javascript/mastodon/features/community_timeline/index.js
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index f64e3b891..62d991ed1 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -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>
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index f9619550c..b7ae7743e 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -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' />
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
index d997634ba..7f7b3e075 100644
--- a/app/javascript/mastodon/features/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
index 094053aa8..902c7a06c 100644
--- a/app/javascript/mastodon/features/home_timeline/components/column_settings.js
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
@@ -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>
     );
   }
 
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
index 8f03adb12..a0a62eaf6 100644
--- a/app/javascript/mastodon/features/home_timeline/index.js
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -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}
         />
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 6c1720b9e..5150a3902 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -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>
     );
   }
 
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index a62dcd50d..60d8fff95 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -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>
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
index 228ee8804..001632004 100644
--- a/app/javascript/mastodon/features/public_timeline/index.js
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
index e8973f595..fb3d35b98 100644
--- a/app/javascript/mastodon/features/ui/components/column.js
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -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);
   }
 
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index ccff417ef..6ed8bc20d 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -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>
     );
   }
diff --git a/app/javascript/mastodon/features/ui/containers/columns_area_container.js b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
new file mode 100644
index 000000000..6420f0784
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/columns_area_container.js
@@ -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);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index af124b1ee..9452e7fcf 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -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 />
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index ababd4983..ad70806b1 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -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;
   }
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
new file mode 100644
index 000000000..c089d37db
--- /dev/null
+++ b/app/javascript/mastodon/scroll.js
@@ -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;