about summary refs log tree commit diff
path: root/app/javascript/mastodon/features
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2017-05-03 02:04:16 +0200
committerGitHub <noreply@github.com>2017-05-03 02:04:16 +0200
commitf5bf5ebb82e3af420dcd23d602b1be6cc86838e1 (patch)
tree92eef08642a038cf44ccbc6d16a884293e7a0814 /app/javascript/mastodon/features
parent26bc5915727e0a0173c03cb49f5193dd612fb888 (diff)
Replace sprockets/browserify with Webpack (#2617)
* Replace browserify with webpack

* Add react-intl-translations-manager

* Do not minify in development, add offline-plugin for ServiceWorker background cache updates

* Adjust tests and dependencies

* Fix production deployments

* Fix tests

* More optimizations

* Improve travis cache for npm stuff

* Re-run travis

* Add back support for custom.scss as before

* Remove offline-plugin and babili

* Fix issue with Immutable.List().unshift(...values) not working as expected

* Make travis load schema instead of running all migrations in sequence

* Fix missing React import in WarningContainer. Optimize rendering performance by using ImmutablePureComponent instead of
React.PureComponent. ImmutablePureComponent uses Immutable.is() to compare props. Replace dynamic callback bindings in
<UI />

* Add react definitions to places that use JSX

* Add Procfile.dev for running rails, webpack and streaming API at the same time
Diffstat (limited to 'app/javascript/mastodon/features')
-rw-r--r--app/javascript/mastodon/features/account/components/action_bar.js93
-rw-r--r--app/javascript/mastodon/features/account/components/header.js150
-rw-r--r--app/javascript/mastodon/features/account_timeline/components/header.js83
-rw-r--r--app/javascript/mastodon/features/account_timeline/containers/header_container.js76
-rw-r--r--app/javascript/mastodon/features/account_timeline/index.js89
-rw-r--r--app/javascript/mastodon/features/blocks/index.js74
-rw-r--r--app/javascript/mastodon/features/community_timeline/index.js96
-rw-r--r--app/javascript/mastodon/features/compose/components/autosuggest_account.js26
-rw-r--r--app/javascript/mastodon/features/compose/components/character_counter.js27
-rw-r--r--app/javascript/mastodon/features/compose/components/compose_form.js211
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js115
-rw-r--r--app/javascript/mastodon/features/compose/components/navigation_bar.js37
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js105
-rw-r--r--app/javascript/mastodon/features/compose/components/reply_indicator.js71
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js82
-rw-r--r--app/javascript/mastodon/features/compose/components/search_results.js67
-rw-r--r--app/javascript/mastodon/features/compose/components/text_icon_button.js36
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_button.js61
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_form.js46
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_progress.js43
-rw-r--r--app/javascript/mastodon/features/compose/components/warning.js26
-rw-r--r--app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js15
-rw-r--r--app/javascript/mastodon/features/compose/containers/compose_form_container.js64
-rw-r--r--app/javascript/mastodon/features/compose/containers/navigation_container.js10
-rw-r--r--app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js17
-rw-r--r--app/javascript/mastodon/features/compose/containers/reply_indicator_container.js24
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/mastodon/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js51
-rw-r--r--app/javascript/mastodon/features/compose/containers/spoiler_button_container.js25
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_button_container.js18
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_form_container.js17
-rw-r--r--app/javascript/mastodon/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/mastodon/features/compose/containers/warning_container.js49
-rw-r--r--app/javascript/mastodon/features/compose/index.js86
-rw-r--r--app/javascript/mastodon/features/favourited_statuses/index.js67
-rw-r--r--app/javascript/mastodon/features/favourites/index.js61
-rw-r--r--app/javascript/mastodon/features/follow_requests/components/account_authorize.js51
-rw-r--r--app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js26
-rw-r--r--app/javascript/mastodon/features/follow_requests/index.js74
-rw-r--r--app/javascript/mastodon/features/followers/index.js92
-rw-r--r--app/javascript/mastodon/features/following/index.js92
-rw-r--r--app/javascript/mastodon/features/generic_not_found/index.js11
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js73
-rw-r--r--app/javascript/mastodon/features/hashtag_timeline/index.js90
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/column_settings.js51
-rw-r--r--app/javascript/mastodon/features/home_timeline/components/setting_text.js38
-rw-r--r--app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js21
-rw-r--r--app/javascript/mastodon/features/home_timeline/index.js38
-rw-r--r--app/javascript/mastodon/features/mutes/index.js75
-rw-r--r--app/javascript/mastodon/features/notifications/components/clear_column_button.js27
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js71
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js90
-rw-r--r--app/javascript/mastodon/features/notifications/components/setting_toggle.js21
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js21
-rw-r--r--app/javascript/mastodon/features/notifications/containers/notification_container.js15
-rw-r--r--app/javascript/mastodon/features/notifications/index.js143
-rw-r--r--app/javascript/mastodon/features/public_timeline/index.js96
-rw-r--r--app/javascript/mastodon/features/reblogs/index.js61
-rw-r--r--app/javascript/mastodon/features/report/components/status_check_box.js40
-rw-r--r--app/javascript/mastodon/features/report/containers/status_check_box_container.js19
-rw-r--r--app/javascript/mastodon/features/report/index.js131
-rw-r--r--app/javascript/mastodon/features/status/components/action_bar.js102
-rw-r--r--app/javascript/mastodon/features/status/components/card.js96
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js96
-rw-r--r--app/javascript/mastodon/features/status/containers/card_container.js8
-rw-r--r--app/javascript/mastodon/features/status/index.js199
-rw-r--r--app/javascript/mastodon/features/ui/components/boost_modal.js84
-rw-r--r--app/javascript/mastodon/features/ui/components/column.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/column_header.js43
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js32
-rw-r--r--app/javascript/mastodon/features/ui/components/column_subheading.js16
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js20
-rw-r--r--app/javascript/mastodon/features/ui/components/confirmation_modal.js51
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.js103
-rw-r--r--app/javascript/mastodon/features/ui/components/modal_root.js93
-rw-r--r--app/javascript/mastodon/features/ui/components/onboarding_modal.js264
-rw-r--r--app/javascript/mastodon/features/ui/components/tabs_bar.js24
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js60
-rw-r--r--app/javascript/mastodon/features/ui/components/video_modal.js40
-rw-r--r--app/javascript/mastodon/features/ui/containers/loading_bar_container.js8
-rw-r--r--app/javascript/mastodon/features/ui/containers/modal_container.js16
-rw-r--r--app/javascript/mastodon/features/ui/containers/notifications_container.js21
-rw-r--r--app/javascript/mastodon/features/ui/containers/status_list_container.js74
-rw-r--r--app/javascript/mastodon/features/ui/index.js169
86 files changed, 5364 insertions, 0 deletions
diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js
new file mode 100644
index 000000000..069348050
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/action_bar.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenu from '../../../components/dropdown_menu';
+import { Link } from 'react-router';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+
+const messages = defineMessages({
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  report: { id: 'account.report', defaultMessage: 'Report @{name}' },
+  disclaimer: { id: 'account.disclaimer', defaultMessage: 'This user is from another instance. This number may be larger.' }
+});
+
+class ActionBar extends React.PureComponent {
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    let menu = [];
+    let extraInfo = '';
+
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    menu.push(null);
+
+    if (account.get('id') === me) {
+      menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+    } else {
+      if (account.getIn(['relationship', 'muting'])) {
+        menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.mute, { name: account.get('username') }), action: this.props.onMute });
+      }
+
+      if (account.getIn(['relationship', 'blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblock, { name: account.get('username') }), action: this.props.onBlock });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.block, { name: account.get('username') }), action: this.props.onBlock });
+      }
+
+      menu.push({ text: intl.formatMessage(messages.report, { name: account.get('username') }), action: this.props.onReport });
+    }
+
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = <abbr title={intl.formatMessage(messages.disclaimer)}>*</abbr>;
+    }
+
+    return (
+      <div className='account__action-bar'>
+        <div className='account__action-bar-dropdown'>
+          <DropdownMenu items={menu} icon='bars' size={24} direction="right" />
+        </div>
+
+        <div className='account__action-bar-links'>
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+            <span><FormattedMessage id='account.posts' defaultMessage='Posts' /></span>
+            <strong><FormattedNumber value={account.get('statuses_count')} /> {extraInfo}</strong>
+          </Link>
+
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+            <span><FormattedMessage id='account.follows' defaultMessage='Follows' /></span>
+            <strong><FormattedNumber value={account.get('following_count')} /> {extraInfo}</strong>
+          </Link>
+
+          <Link className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+            <span><FormattedMessage id='account.followers' defaultMessage='Followers' /></span>
+            <strong><FormattedNumber value={account.get('followers_count')} /> {extraInfo}</strong>
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ActionBar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func,
+  onBlock: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
new file mode 100644
index 000000000..fbaa5e9e6
--- /dev/null
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -0,0 +1,150 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import { Motion, spring } from 'react-motion';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = (state, props) => ({
+    autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
+  });
+
+  return mapStateToProps;
+};
+
+class Avatar extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.state = {
+      isHovered: false
+    };
+
+    this.handleMouseOver = this.handleMouseOver.bind(this);
+    this.handleMouseOut = this.handleMouseOut.bind(this);
+  }
+
+  handleMouseOver () {
+    if (this.state.isHovered) return;
+    this.setState({ isHovered: true });
+  }
+
+  handleMouseOut () {
+    if (!this.state.isHovered) return;
+    this.setState({ isHovered: false });
+  }
+
+  render () {
+    const { account, autoPlayGif }   = this.props;
+    const { isHovered } = this.state;
+
+    return (
+      <Motion defaultStyle={{ radius: 90 }} style={{ radius: spring(isHovered ? 30 : 90, { stiffness: 180, damping: 12 }) }}>
+        {({ radius }) =>
+          <a
+            href={account.get('url')}
+            className='account__header__avatar'
+            target='_blank'
+            rel='noopener'
+            style={{ borderRadius: `${radius}px`, backgroundImage: `url(${autoPlayGif || isHovered ? account.get('avatar') : account.get('avatar_static')})` }}
+            onMouseOver={this.handleMouseOver}
+            onMouseOut={this.handleMouseOut}
+            onFocus={this.handleMouseOver}
+            onBlur={this.handleMouseOut}
+          />
+        }
+      </Motion>
+    );
+  }
+
+}
+
+Avatar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+class Header extends ImmutablePureComponent {
+
+  render () {
+    const { account, me, intl } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    let displayName = account.get('display_name');
+    let info        = '';
+    let actionBtn   = '';
+    let lockedIcon  = '';
+
+    if (displayName.length === 0) {
+      displayName = account.get('username');
+    }
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info = <span className='account--follows-info' style={{ position: 'absolute', top: '10px', right: '10px', opacity: '0.7', display: 'inline-block', verticalAlign: 'top', background: 'rgba(0, 0, 0, 0.4)', textTransform: 'uppercase', fontSize: '11px', fontWeight: '500', padding: '4px', borderRadius: '4px' }}><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>
+    }
+
+    if (me !== account.get('id')) {
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+            <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
+          </div>
+        );
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = (
+          <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+            <IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
+          </div>
+        );
+      }
+    }
+
+    if (account.get('locked')) {
+      lockedIcon = <i className='fa fa-lock' />;
+    }
+
+    const content         = { __html: emojify(account.get('note')) };
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+
+    return (
+      <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+        <div style={{ padding: '20px 10px' }}>
+          <Avatar account={account} autoPlayGif={this.props.autoPlayGif} />
+
+          <span style={{ display: 'inline-block', fontSize: '20px', lineHeight: '27px', fontWeight: '500' }} className='account__header__display-name' dangerouslySetInnerHTML={displayNameHTML} />
+          <span className='account__header__username' style={{ fontSize: '14px', fontWeight: '400', display: 'block', marginBottom: '10px' }}>@{account.get('acct')} {lockedIcon}</span>
+          <div style={{ fontSize: '14px' }} className='account__header__content' dangerouslySetInnerHTML={content} />
+
+          {info}
+          {actionBtn}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Header.propTypes = {
+  account: ImmutablePropTypes.map,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  autoPlayGif: PropTypes.bool.isRequired
+};
+
+export default connect(makeMapStateToProps)(injectIntl(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
new file mode 100644
index 000000000..b4dca3a57
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from '../../account/components/header';
+import ActionBar from '../../account/components/action_bar';
+import MissingIndicator from '../../../components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class Header extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFollow = this.handleFollow.bind(this);
+    this.handleBlock = this.handleBlock.bind(this);
+    this.handleMention = this.handleMention.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+    this.handleMute = this.handleMute.bind(this);
+  }
+
+  handleFollow () {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock () {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMention () {
+    this.props.onMention(this.props.account, this.context.router);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.account);
+    this.context.router.push('/report');
+  }
+
+  handleMute() {
+    this.props.onMute(this.props.account);
+  }
+
+  render () {
+    const { account, me } = this.props;
+
+    if (account === null) {
+      return <MissingIndicator />;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        <InnerHeader
+          account={account}
+          me={me}
+          onFollow={this.handleFollow}
+        />
+
+        <ActionBar
+          account={account}
+          me={me}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+        />
+      </div>
+    );
+  }
+}
+
+Header.propTypes = {
+  account: ImmutablePropTypes.map,
+  me: PropTypes.number.isRequired,
+  onFollow: PropTypes.func.isRequired,
+  onBlock: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func.isRequired,
+  onMute: PropTypes.func.isRequired
+};
+
+Header.contextTypes = {
+  router: PropTypes.object
+};
+
+export default Header;
diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..50999d2e0
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  muteAccount,
+  unmuteAccount
+} from '../../../actions/accounts';
+import { mentionCompose } from '../../../actions/compose';
+import { initReport } from '../../../actions/reports';
+import { openModal } from '../../../actions/modal';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  muteConfirm: { id: 'confirmations.mute.confirm', defaultMessage: 'Mute' }
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, Number(accountId)),
+    me: state.getIn(['meta', 'me'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following'])) {
+      dispatch(unfollowAccount(account.get('id')));
+    } else {
+      dispatch(followAccount(account.get('id')));
+    }
+  },
+
+  onBlock (account) {
+    if (account.getIn(['relationship', 'blocking'])) {
+      dispatch(unblockAccount(account.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: <FormattedMessage id='confirmations.block.message' defaultMessage='Are you sure you want to block {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+        confirm: intl.formatMessage(messages.blockConfirm),
+        onConfirm: () => dispatch(blockAccount(account.get('id')))
+      }));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: <FormattedMessage id='confirmations.mute.message' defaultMessage='Are you sure you want to mute {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+        confirm: intl.formatMessage(messages.muteConfirm),
+        onConfirm: () => dispatch(muteAccount(account.get('id')))
+      }));
+    }
+  }
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/mastodon/features/account_timeline/index.js b/app/javascript/mastodon/features/account_timeline/index.js
new file mode 100644
index 000000000..fb76f4d2e
--- /dev/null
+++ b/app/javascript/mastodon/features/account_timeline/index.js
@@ -0,0 +1,89 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import {
+  fetchAccount,
+  fetchAccountTimeline,
+  expandAccountTimeline
+} from '../../actions/accounts';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import Immutable from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  statusIds: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'items'], Immutable.List()),
+  isLoading: state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'isLoading']),
+  hasMore: !!state.getIn(['timelines', 'accounts_timelines', Number(props.params.accountId), 'next']),
+  me: state.getIn(['meta', 'me'])
+});
+
+class AccountTimeline extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchAccountTimeline(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchAccountTimeline(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScrollToBottom () {
+    if (!this.props.isLoading && this.props.hasMore) {
+      this.props.dispatch(expandAccountTimeline(Number(this.props.params.accountId)));
+    }
+  }
+
+  render () {
+    const { statusIds, isLoading, hasMore, me } = this.props;
+
+    if (!statusIds && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          scrollKey='account_timeline'
+          statusIds={statusIds}
+          isLoading={isLoading}
+          hasMore={hasMore}
+          me={me}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </Column>
+    );
+  }
+
+}
+
+AccountTimeline.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  statusIds: ImmutablePropTypes.list,
+  isLoading: PropTypes.bool,
+  hasMore: PropTypes.bool,
+  me: PropTypes.number.isRequired
+};
+
+export default connect(mapStateToProps)(AccountTimeline);
diff --git a/app/javascript/mastodon/features/blocks/index.js b/app/javascript/mastodon/features/blocks/index.js
new file mode 100644
index 000000000..e25d9b2b4
--- /dev/null
+++ b/app/javascript/mastodon/features/blocks/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchBlocks, expandBlocks } from '../../actions/blocks';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.blocks', defaultMessage: 'Blocked users' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'blocks', 'items'])
+});
+
+class Blocks extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandBlocks());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='ban' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='blocks'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+}
+
+Blocks.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Blocks));
diff --git a/app/javascript/mastodon/features/community_timeline/index.js b/app/javascript/mastodon/features/community_timeline/index.js
new file mode 100644
index 000000000..883263631
--- /dev/null
+++ b/app/javascript/mastodon/features/community_timeline/index.js
@@ -0,0 +1,96 @@
+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 {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local timeline' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+class CommunityTimeline extends React.PureComponent {
+
+  componentDidMount () {
+    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+
+    dispatch(refreshTimeline('community'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(streamingAPIBaseURL, accessToken, 'public:local', {
+
+      connected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('community'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('community'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('community', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  }
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    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>
+    );
+  }
+
+}
+
+CommunityTimeline.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(CommunityTimeline));
diff --git a/app/javascript/mastodon/features/compose/components/autosuggest_account.js b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..3d87c4649
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/autosuggest_account.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class AutosuggestAccount extends ImmutablePureComponent {
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='autosuggest-account'>
+        <div className='autosuggest-account-icon'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    );
+  }
+
+}
+
+AutosuggestAccount.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
+export default AutosuggestAccount;
diff --git a/app/javascript/mastodon/features/compose/components/character_counter.js b/app/javascript/mastodon/features/compose/components/character_counter.js
new file mode 100644
index 000000000..617f85cfe
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/character_counter.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+class CharacterCounter extends React.PureComponent {
+
+  checkRemainingText (diff) {
+    if (diff < 0) {
+      return <span className='character-counter character-counter--over'>{diff}</span>;
+    }
+    return <span className='character-counter'>{diff}</span>;
+  }
+
+  render () {
+    const diff = this.props.max - length(this.props.text);
+
+    return this.checkRemainingText(diff);
+  }
+
+}
+
+CharacterCounter.propTypes = {
+  text: PropTypes.string.isRequired,
+  max: PropTypes.number.isRequired
+}
+
+export default CharacterCounter;
diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js
new file mode 100644
index 000000000..0b9c097e3
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/compose_form.js
@@ -0,0 +1,211 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from '../../../components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import { debounce } from 'react-decoration';
+import UploadButtonContainer from '../containers/upload_button_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Collapsable from '../../../components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from './emoji_picker_dropdown';
+import UploadFormContainer from '../containers/upload_form_container';
+import TextIconButton from './text_icon_button';
+import WarningContainer from '../containers/warning_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Content warning' },
+  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' }
+});
+
+class ComposeForm extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleKeyDown = this.handleKeyDown.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+    this.onSuggestionsClearRequested = this.onSuggestionsClearRequested.bind(this);
+    this.onSuggestionsFetchRequested = this.onSuggestionsFetchRequested.bind(this);
+    this.onSuggestionSelected = this.onSuggestionSelected.bind(this);
+    this.handleChangeSpoilerText = this.handleChangeSpoilerText.bind(this);
+    this.setAutosuggestTextarea = this.setAutosuggestTextarea.bind(this);
+    this.handleEmojiPick = this.handleEmojiPick.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown (e) {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit () {
+    this.autosuggestTextarea.reset();
+    this.props.onSubmit();
+  }
+
+  onSuggestionsClearRequested () {
+    this.props.onClearSuggestions();
+  }
+
+  @debounce(500)
+  onSuggestionsFetchRequested (token) {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected (tokenStart, token, value) {
+    this._restoreCaret = null;
+    this.props.onSuggestionSelected(tokenStart, token, value);
+  }
+
+  handleChangeSpoilerText (e) {
+    this.props.onChangeSpoilerText(e.target.value);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    // If this is the update where we've finished uploading,
+    // save the last caret position so we can restore it below!
+    if (!nextProps.is_uploading && this.props.is_uploading) {
+      this._restoreCaret = this.autosuggestTextarea.textarea.selectionStart;
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    // This statement does several things:
+    // - If we're beginning a reply, and,
+    //     - Replying to zero or one users, places the cursor at the end of the textbox.
+    //     - Replying to more than one user, selects any usernames past the first;
+    //       this provides a convenient shortcut to drop everyone else from the conversation.
+    // - If we've just finished uploading an image, and have a saved caret position,
+    //   restores the cursor to that position after the text changes!
+    if (this.props.focusDate !== prevProps.focusDate || (prevProps.is_uploading && !this.props.is_uploading && typeof this._restoreCaret === 'number')) {
+      let selectionEnd, selectionStart;
+
+      if (this.props.preselectDate !== prevProps.preselectDate) {
+        selectionEnd   = this.props.text.length;
+        selectionStart = this.props.text.search(/\s/) + 1;
+      } else if (typeof this._restoreCaret === 'number') {
+        selectionStart = this._restoreCaret;
+        selectionEnd   = this._restoreCaret;
+      } else {
+        selectionEnd   = this.props.text.length;
+        selectionStart = selectionEnd;
+      }
+
+      this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
+      this.autosuggestTextarea.textarea.focus();
+    }
+  }
+
+  setAutosuggestTextarea (c) {
+    this.autosuggestTextarea = c;
+  }
+
+  handleEmojiPick (data) {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    this._restoreCaret = position + data.shortname.length + 1;
+    this.props.onPickEmoji(position, data);
+  }
+
+  render () {
+    const { intl, onPaste } = this.props;
+    const disabled = this.props.is_submitting;
+    const text = [this.props.spoiler_text, this.props.text].join('');
+
+    let publishText    = '';
+    let reply_to_other = false;
+
+    if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
+      publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
+    } else {
+      publishText = intl.formatMessage(messages.publish) + (this.props.privacy !== 'unlisted' ? '!' : '');
+    }
+
+    return (
+      <div className='compose-form'>
+        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
+          <div className="spoiler-input">
+            <input placeholder={intl.formatMessage(messages.spoiler_placeholder)} value={this.props.spoiler_text} onChange={this.handleChangeSpoilerText} onKeyDown={this.handleKeyDown} type="text" className="spoiler-input__input"  id='cw-spoiler-input'/>
+          </div>
+        </Collapsable>
+
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className='compose-form__autosuggest-wrapper'>
+          <AutosuggestTextarea
+            ref={this.setAutosuggestTextarea}
+            placeholder={intl.formatMessage(messages.placeholder)}
+            disabled={disabled}
+            value={this.props.text}
+            onChange={this.handleChange}
+            suggestions={this.props.suggestions}
+            onKeyDown={this.handleKeyDown}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            onPaste={onPaste}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
+
+        <div className='compose-form__buttons-wrapper'>
+          <div className='compose-form__buttons'>
+            <UploadButtonContainer />
+            <PrivacyDropdownContainer />
+            <SensitiveButtonContainer />
+            <SpoilerButtonContainer />
+          </div>
+
+          <div className='compose-form__publish'>
+            <div className='character-counter__wrapper'><CharacterCounter max={500} text={text} /></div>
+            <div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabled || text.replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, "_").length > 500 || (text.length !==0 && text.trim().length === 0)} block /></div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ComposeForm.propTypes = {
+  intl: PropTypes.object.isRequired,
+  text: PropTypes.string.isRequired,
+  suggestion_token: PropTypes.string,
+  suggestions: ImmutablePropTypes.list,
+  spoiler: PropTypes.bool,
+  privacy: PropTypes.string,
+  spoiler_text: PropTypes.string,
+  focusDate: PropTypes.instanceOf(Date),
+  preselectDate: PropTypes.instanceOf(Date),
+  is_submitting: PropTypes.bool,
+  is_uploading: PropTypes.bool,
+  me: PropTypes.number,
+  onChange: PropTypes.func.isRequired,
+  onSubmit: PropTypes.func.isRequired,
+  onClearSuggestions: PropTypes.func.isRequired,
+  onFetchSuggestions: PropTypes.func.isRequired,
+  onSuggestionSelected: PropTypes.func.isRequired,
+  onChangeSpoilerText: PropTypes.func.isRequired,
+  onPaste: PropTypes.func.isRequired,
+  onPickEmoji: PropTypes.func.isRequired
+};
+
+export default injectIntl(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..3e0b290d6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
+import EmojiPicker from 'emojione-picker';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  people: { id: 'emoji_button.people', defaultMessage: 'People' },
+  nature: { id: 'emoji_button.nature', defaultMessage: 'Nature' },
+  food: { id: 'emoji_button.food', defaultMessage: 'Food & Drink' },
+  activity: { id: 'emoji_button.activity', defaultMessage: 'Activity' },
+  travel: { id: 'emoji_button.travel', defaultMessage: 'Travel & Places' },
+  objects: { id: 'emoji_button.objects', defaultMessage: 'Objects' },
+  symbols: { id: 'emoji_button.symbols', defaultMessage: 'Symbols' },
+  flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }
+});
+
+const settings = {
+  imageType: 'png',
+  sprites: false,
+  imagePathPNG: '/emoji/'
+};
+
+const dropdownStyle = {
+  position: 'absolute',
+  right: '5px',
+  top: '5px'
+};
+
+const dropdownTriggerStyle = {
+  display: 'block',
+  fontSize: '24px',
+  lineHeight: '24px',
+  marginLeft: '2px',
+  width: '24px'
+}
+
+class EmojiPickerDropdown extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.setRef = this.setRef.bind(this);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  setRef (c) {
+    this.dropdown = c;
+  }
+
+  handleChange (data) {
+    this.dropdown.hide();
+    this.props.onPickEmoji(data);
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    const categories = {
+      people: {
+        title: intl.formatMessage(messages.people),
+        emoji: 'smile',
+      },
+      nature: {
+        title: intl.formatMessage(messages.nature),
+        emoji: 'hamster',
+      },
+      food: {
+        title: intl.formatMessage(messages.food),
+        emoji: 'pizza',
+      },
+      activity: {
+        title: intl.formatMessage(messages.activity),
+        emoji: 'soccer',
+      },
+      travel: {
+        title: intl.formatMessage(messages.travel),
+        emoji: 'earth_americas',
+      },
+      objects: {
+        title: intl.formatMessage(messages.objects),
+        emoji: 'bulb',
+      },
+      symbols: {
+        title: intl.formatMessage(messages.symbols),
+        emoji: 'clock9',
+      },
+      flags: {
+        title: intl.formatMessage(messages.flags),
+        emoji: 'flag_gb',
+      }
+    }
+
+    return (
+      <Dropdown ref={this.setRef} style={dropdownStyle}>
+        <DropdownTrigger className='emoji-button' title={intl.formatMessage(messages.emoji)} style={dropdownTriggerStyle}>
+          <img draggable="false" className="emojione" alt="🙂" src="/emoji/1f602.svg" />
+        </DropdownTrigger>
+
+        <DropdownContent className='dropdown__left'>
+          <EmojiPicker emojione={settings} onChange={this.handleChange} searchPlaceholder={intl.formatMessage(messages.emoji_search)} categories={categories} search={true} />
+        </DropdownContent>
+      </Dropdown>
+    );
+  }
+
+}
+
+EmojiPickerDropdown.propTypes = {
+  intl: PropTypes.object.isRequired,
+  onPickEmoji: PropTypes.func.isRequired
+};
+
+export default injectIntl(EmojiPickerDropdown);
diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..aec8f6153
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import Permalink from '../../../components/permalink';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class NavigationBar extends ImmutablePureComponent {
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <Avatar src={this.props.account.get('avatar')} animate size={40} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          <a href='/settings/profile' className='navigation-bar__profile-edit'><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+NavigationBar.propTypes = {
+  account: ImmutablePropTypes.map.isRequired
+};
+
+export default NavigationBar;
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..b77d55f4d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
+  public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
+  unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
+  unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' },
+  private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' },
+  private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
+  direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
+  direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },
+  change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }
+});
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px'
+}
+
+class PrivacyDropdown extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      open: false
+    };
+    this.handleToggle = this.handleToggle.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+    this.onGlobalClick = this.onGlobalClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleToggle () {
+    this.setState({ open: !this.state.open });
+  }
+
+  handleClick (value, e) {
+    e.preventDefault();
+    this.setState({ open: false });
+    this.props.onChange(value);
+  }
+
+  onGlobalClick (e) {
+    if (e.target !== this.node && !this.node.contains(e.target) && this.state.open) {
+      this.setState({ open: false });
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('click', this.onGlobalClick);
+    window.addEventListener('touchstart', this.onGlobalClick);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('click', this.onGlobalClick);
+    window.removeEventListener('touchstart', this.onGlobalClick);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { value, onChange, intl } = this.props;
+    const { open } = this.state;
+
+    const options = [
+      { icon: 'globe', value: 'public', shortText: intl.formatMessage(messages.public_short), longText: intl.formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', shortText: intl.formatMessage(messages.unlisted_short), longText: intl.formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', shortText: intl.formatMessage(messages.private_short), longText: intl.formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', shortText: intl.formatMessage(messages.direct_short), longText: intl.formatMessage(messages.direct_long) }
+    ];
+
+    const valueOption = options.find(item => item.value === value);
+
+    return (
+      <div ref={this.setRef} className={`privacy-dropdown ${open ? 'active' : ''}`}>
+        <div className='privacy-dropdown__value'><IconButton className='privacy-dropdown__value-icon' icon={valueOption.icon} title={intl.formatMessage(messages.change_privacy)} size={18} active={open} inverted onClick={this.handleToggle} style={iconStyle}/></div>
+        <div className='privacy-dropdown__dropdown'>
+          {options.map(item =>
+            <div role='button' tabIndex='0' key={item.value} onClick={this.handleClick.bind(this, item.value)} className={`privacy-dropdown__option ${item.value === value ? 'active' : ''}`}>
+              <div className='privacy-dropdown__option__icon'><i className={`fa fa-fw fa-${item.icon}`} /></div>
+              <div className='privacy-dropdown__option__content'>
+                <strong>{item.shortText}</strong>
+                {item.longText}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+}
+
+PrivacyDropdown.propTypes = {
+  value: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/components/reply_indicator.js b/app/javascript/mastodon/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..e53831b60
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/reply_indicator.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from '../../../components/avatar';
+import IconButton from '../../../components/icon_button';
+import DisplayName from '../../../components/display_name';
+import emojify from '../../../emoji';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' }
+});
+
+class ReplyIndicator extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleClick () {
+    this.props.onCancel();
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: emojify(status.get('content')) };
+
+    return (
+      <div className='reply-indicator'>
+        <div className='reply-indicator__header'>
+          <div className='reply-indicator__cancel'><IconButton title={intl.formatMessage(messages.cancel)} icon='times' onClick={this.handleClick} /></div>
+
+          <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='reply-indicator__display-name'>
+            <div className='reply-indicator__display-avatar'><Avatar size={24} src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} /></div>
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+}
+
+ReplyIndicator.contextTypes = {
+  router: PropTypes.object
+};
+
+ReplyIndicator.propTypes = {
+  status: ImmutablePropTypes.map,
+  onCancel: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ReplyIndicator);
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
new file mode 100644
index 000000000..61ae9ce23
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -0,0 +1,82 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' }
+});
+
+class Search extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleKeyDown = this.handleKeyDown.bind(this);
+    this.handleFocus = this.handleFocus.bind(this);
+    this.handleClear = this.handleClear.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(e.target.value);
+  }
+
+  handleClear (e) {
+    e.preventDefault();
+
+    if (this.props.value.length > 0 || this.props.submitted) {
+      this.props.onClear();
+    }
+  }
+
+  handleKeyDown (e) {
+    if (e.key === 'Enter') {
+      e.preventDefault();
+      this.props.onSubmit();
+    }
+  }
+
+  noop () {
+
+  }
+
+  handleFocus () {
+    this.props.onShow();
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <input
+          className='search__input'
+          type='text'
+          placeholder={intl.formatMessage(messages.placeholder)}
+          value={value}
+          onChange={this.handleChange}
+          onKeyUp={this.handleKeyDown}
+          onFocus={this.handleFocus}
+        />
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <i className={`fa fa-search ${hasValue ? '' : 'active'}`} />
+          <i aria-label={intl.formatMessage(messages.placeholder)} className={`fa fa-times-circle ${hasValue ? 'active' : ''}`} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Search.propTypes = {
+  value: PropTypes.string.isRequired,
+  submitted: PropTypes.bool,
+  onChange: PropTypes.func.isRequired,
+  onSubmit: PropTypes.func.isRequired,
+  onClear: PropTypes.func.isRequired,
+  onShow: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(Search);
diff --git a/app/javascript/mastodon/features/compose/components/search_results.js b/app/javascript/mastodon/features/compose/components/search_results.js
new file mode 100644
index 000000000..79e880f0a
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/search_results.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import AccountContainer from '../../../containers/account_container';
+import StatusContainer from '../../../containers/status_container';
+import { Link } from 'react-router';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class SearchResults extends ImmutablePureComponent {
+
+  render () {
+    const { results } = this.props;
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <div className='search-results__section'>
+          {results.get('accounts').map(accountId => <AccountContainer key={accountId} id={accountId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <div className='search-results__section'>
+          {results.get('statuses').map(statusId => <StatusContainer key={statusId} id={statusId} />)}
+        </div>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <div className='search-results__section'>
+          {results.get('hashtags').map(hashtag =>
+            <Link className='search-results__hashtag' to={`/timelines/tag/${hashtag}`}>
+              #{hashtag}
+            </Link>
+          )}
+        </div>
+      );
+    }
+
+    return (
+      <div className='search-results'>
+        <div className='search-results__header'>
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </div>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  }
+
+}
+
+SearchResults.propTypes = {
+  results: ImmutablePropTypes.map.isRequired
+};
+
+export default SearchResults;
diff --git a/app/javascript/mastodon/features/compose/components/text_icon_button.js b/app/javascript/mastodon/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..bcfa21090
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/text_icon_button.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class TextIconButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick (e) {
+    e.preventDefault();
+    this.props.onClick();
+  }
+
+  render () {
+    const { label, title, active, ariaControls } = this.props;
+
+    return (
+      <button title={title} aria-label={title} className={`text-icon-button ${active ? 'active' : ''}`} aria-expanded={active} onClick={this.handleClick} aria-controls={ariaControls}>
+        {label}
+      </button>
+    );
+  }
+
+}
+
+TextIconButton.propTypes = {
+  label: PropTypes.string.isRequired,
+  title: PropTypes.string,
+  active: PropTypes.bool,
+  onClick: PropTypes.func.isRequired,
+  ariaControls: PropTypes.string
+};
+
+export default TextIconButton;
diff --git a/app/javascript/mastodon/features/compose/components/upload_button.js b/app/javascript/mastodon/features/compose/components/upload_button.js
new file mode 100644
index 000000000..15ec2edd6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_button.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import IconButton from '../../../components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  upload: { id: 'upload_button.label', defaultMessage: 'Add media' }
+});
+
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px'
+}
+
+class UploadButton extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+    this.handleClick = this.handleClick.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleChange (e) {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  }
+
+  handleClick () {
+    this.fileElement.click();
+  }
+
+  setRef (c) {
+    this.fileElement = c;
+  }
+
+  render () {
+
+    const { intl, resetFileKey, disabled } = this.props;
+
+    return (
+      <div className='compose-form__upload-button'>
+        <IconButton icon='camera' title={intl.formatMessage(messages.upload)} disabled={disabled} onClick={this.handleClick} className='compose-form__upload-button-icon' size={18} inverted style={iconStyle}/>
+        <input key={resetFileKey} ref={this.setRef} type='file' multiple={false} onChange={this.handleChange} disabled={disabled} style={{ display: 'none' }} />
+      </div>
+    );
+  }
+
+}
+
+UploadButton.propTypes = {
+  disabled: PropTypes.bool,
+  onSelectFile: PropTypes.func.isRequired,
+  style: PropTypes.object,
+  resetFileKey: PropTypes.number,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(UploadButton);
diff --git a/app/javascript/mastodon/features/compose/components/upload_form.js b/app/javascript/mastodon/features/compose/components/upload_form.js
new file mode 100644
index 000000000..8e48538da
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_form.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import { Motion, spring } from 'react-motion';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' }
+});
+
+class UploadForm extends React.PureComponent {
+
+  render () {
+    const { intl, media } = this.props;
+
+    const uploads = media.map(attachment =>
+      <div className='compose-form__upload' key={attachment.get('id')}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) =>
+            <div className='compose-form__upload-thumbnail' style={{ transform: `translateZ(0) scale(${scale})`, backgroundImage: `url(${attachment.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.props.onRemoveFile.bind(this, attachment.get('id'))} />
+            </div>
+          }
+        </Motion>
+      </div>
+    );
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+        <div className='compose-form__uploads-wrapper'>{uploads}</div>
+      </div>
+    );
+  }
+
+}
+
+UploadForm.propTypes = {
+  media: ImmutablePropTypes.list.isRequired,
+  onRemoveFile: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..bb2932a55
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+class UploadProgress extends React.PureComponent {
+
+  render () {
+    const { active, progress } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='upload-progress'>
+        <div className='upload-progress__icon'>
+          <i className='fa fa-upload' />
+        </div>
+
+        <div className='upload-progress__message'>
+          <FormattedMessage id='upload_progress.label' defaultMessage='Uploading...' />
+
+          <div className='upload-progress__backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                <div className='upload-progress__tracker' style={{ width: `${width}%` }} />
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+UploadProgress.propTypes = {
+  active: PropTypes.bool,
+  progress: PropTypes.number
+};
+
+export default UploadProgress;
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
new file mode 100644
index 000000000..6ad00b691
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class Warning extends React.PureComponent {
+
+  constructor (props) {
+    super(props);
+  }
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <div className='compose-form__warning'>
+        {message}
+      </div>
+    );
+  }
+
+}
+
+Warning.propTypes = {
+  message: PropTypes.node.isRequired
+};
+
+export default Warning;
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..de76a364d
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/autosuggest_account_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestAccount from '../components/autosuggest_account';
+import { makeGetAccount } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestAccount);
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
new file mode 100644
index 000000000..ef46eb09c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import AutosuggestStatus from '../components/autosuggest_status';
+import { makeGetStatus } from '../../../selectors';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, { id }) => ({
+    status: getStatus(state, id)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(AutosuggestStatus);
diff --git a/app/javascript/mastodon/features/compose/containers/compose_form_container.js b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..892183b83
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/compose_form_container.js
@@ -0,0 +1,64 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import { uploadCompose } from '../../../actions/compose';
+import {
+  changeCompose,
+  submitCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+  changeComposeSpoilerText,
+  insertEmojiCompose
+} from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  text: state.getIn(['compose', 'text']),
+  suggestion_token: state.getIn(['compose', 'suggestion_token']),
+  suggestions: state.getIn(['compose', 'suggestions']),
+  spoiler: state.getIn(['compose', 'spoiler']),
+  spoiler_text: state.getIn(['compose', 'spoiler_text']),
+  privacy: state.getIn(['compose', 'privacy']),
+  focusDate: state.getIn(['compose', 'focusDate']),
+  preselectDate: state.getIn(['compose', 'preselectDate']),
+  is_submitting: state.getIn(['compose', 'is_submitting']),
+  is_uploading: state.getIn(['compose', 'is_uploading']),
+  me: state.getIn(['compose', 'me'])
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit () {
+    dispatch(submitCompose());
+  },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId) {
+    dispatch(selectComposeSuggestion(position, token, accountId));
+  },
+
+  onChangeSpoilerText (checked) {
+    dispatch(changeComposeSpoilerText(checked));
+  },
+
+  onPaste (files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji (position, data) {
+    dispatch(insertEmojiCompose(position, data));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeForm);
diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..0006608da
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js
@@ -0,0 +1,10 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+
+const mapStateToProps = (state, props) => {
+  return {
+    account: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..1eee8f84c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['compose', 'privacy'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..39b48f3b6
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from '../../../actions/compose';
+import { makeGetStatus } from '../../../selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, state.getIn(['compose', 'in_reply_to'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/mastodon/features/compose/containers/search_container.js b/app/javascript/mastodon/features/compose/containers/search_container.js
new file mode 100644
index 000000000..906c0c28c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch
+} from '../../../actions/search';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['search', 'value']),
+  submitted: state.getIn(['search', 'submitted'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeSearch(value));
+  },
+
+  onClear () {
+    dispatch(clearSearch());
+  },
+
+  onSubmit () {
+    dispatch(submitSearch());
+  },
+
+  onShow () {
+    dispatch(showSearch());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Search);
diff --git a/app/javascript/mastodon/features/compose/containers/search_results_container.js b/app/javascript/mastodon/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..e5911fd38
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/search_results_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results'])
+});
+
+export default connect(mapStateToProps)(SearchResults);
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..78e40e048
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSensitivity } from '../../../actions/compose';
+import { Motion, spring } from 'react-motion';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.sensitive', defaultMessage: 'Mark media as sensitive' }
+});
+
+const mapStateToProps = state => ({
+  visible: state.getIn(['compose', 'media_attachments']).size > 0,
+  active: state.getIn(['compose', 'sensitive'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  }
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  render () {
+    const { visible, active, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) =>
+          <div style={{ display: visible ? 'block' : 'none', transform: `translateZ(0) scale(${scale})` }}>
+            <TextIconButton onClick={onClick} label='NSFW' title={intl.formatMessage(messages.title)} active={active} />
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+SensitiveButton.propTypes = {
+  visible: PropTypes.bool,
+  active: PropTypes.bool,
+  onClick: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
new file mode 100644
index 000000000..b1c80fe19
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/spoiler_button_container.js
@@ -0,0 +1,25 @@
+import { connect } from 'react-redux';
+import TextIconButton from '../components/text_icon_button';
+import { changeComposeSpoilerness } from '../../../actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'compose_form.spoiler', defaultMessage: 'Hide text behind warning' }
+});
+
+const mapStateToProps = (state, { intl }) => ({
+  label: 'CW',
+  title: intl.formatMessage(messages.title),
+  active: state.getIn(['compose', 'spoiler']),
+  ariaControls: 'cw-spoiler-input'
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSpoilerness());
+  }
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TextIconButton));
diff --git a/app/javascript/mastodon/features/compose/containers/upload_button_container.js b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
new file mode 100644
index 000000000..78e5312f5
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_button_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import UploadButton from '../components/upload_button';
+import { uploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = state => ({
+  disabled: state.getIn(['compose', 'is_uploading']) || (state.getIn(['compose', 'media_attachments']).size > 3 || state.getIn(['compose', 'media_attachments']).some(m => m.get('type') === 'video')),
+  resetFileKey: state.getIn(['compose', 'resetFileKey'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onSelectFile (files) {
+    dispatch(uploadCompose(files));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadButton);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_form_container.js b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6a202e17
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_form_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+import { undoUploadCompose } from '../../../actions/compose';
+
+const mapStateToProps = (state, props) => ({
+  media: state.getIn(['compose', 'media_attachments']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onRemoveFile (media_id) {
+    dispatch(undoUploadCompose(media_id));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(UploadForm);
diff --git a/app/javascript/mastodon/features/compose/containers/upload_progress_container.js b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..b0f1d4d19
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/upload_progress_container.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import UploadProgress from '../components/upload_progress';
+
+const mapStateToProps = (state, props) => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress'])
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/mastodon/features/compose/containers/warning_container.js b/app/javascript/mastodon/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..bf5e6a5f8
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/containers/warning_container.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import { createSelector } from 'reselect';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig));
+
+const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => {
+  return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : [];
+});
+
+const mapStateToProps = state => {
+  const mentionedUsernames = getMentionedUsernames(state);
+  const mentionedUsernamesWithDomains = getMentionedDomains(state);
+
+  return {
+    needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null,
+    mentionedDomains: mentionedUsernamesWithDomains,
+    needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked'])
+  };
+};
+
+const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => {
+  if (needsLockWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  } else if (needsLeakWarning) {
+    return (
+      <Warning
+        message={<FormattedMessage
+          id='compose_form.privacy_disclaimer'
+          defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.'
+          values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }}
+        />}
+      />
+    );
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLeakWarning: PropTypes.bool,
+  needsLockWarning: PropTypes.bool,
+  mentionedDomains: PropTypes.array.isRequired,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
new file mode 100644
index 000000000..68d779c6c
--- /dev/null
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import UploadFormContainer from './containers/upload_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { mountCompose, unmountCompose } from '../../actions/compose';
+import { Link } from 'react-router';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import { Motion, spring } from 'react-motion';
+import SearchResultsContainer from './containers/search_results_container';
+
+const messages = defineMessages({
+  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }
+});
+
+const mapStateToProps = state => ({
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden'])
+});
+
+class Compose extends React.PureComponent {
+
+  componentDidMount () {
+    this.props.dispatch(mountCompose());
+  }
+
+  componentWillUnmount () {
+    this.props.dispatch(unmountCompose());
+  }
+
+  render () {
+    const { withHeader, showSearch, intl } = this.props;
+
+    let header = '';
+
+    if (withHeader) {
+      header = (
+        <div className='drawer__header'>
+          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)}><i role="img" aria-label={intl.formatMessage(messages.start)} className='fa fa-fw fa-asterisk' /></Link>
+          <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)}><i role="img" aria-label={intl.formatMessage(messages.community)} className='fa fa-fw fa-users' /></Link>
+          <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)}><i role="img" aria-label={intl.formatMessage(messages.public)} className='fa fa-fw fa-globe' /></Link>
+          <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)}><i role="img" aria-label={intl.formatMessage(messages.preferences)} className='fa fa-fw fa-cog' /></a>
+          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)}><i role="img" aria-label={intl.formatMessage(messages.logout)} className='fa fa-fw fa-sign-out' /></a>
+        </div>
+      );
+    }
+
+    return (
+      <div className='drawer'>
+        {header}
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner'>
+            <NavigationContainer />
+            <ComposeFormContainer />
+          </div>
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              <div className='drawer__inner darker' style={{ transform: `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                <SearchResultsContainer />
+              </div>
+            }
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+Compose.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  withHeader: PropTypes.bool,
+  showSearch: PropTypes.bool,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Compose));
diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js
new file mode 100644
index 000000000..995f61f17
--- /dev/null
+++ b/app/javascript/mastodon/features/favourited_statuses/index.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites';
+import Column from '../ui/components/column';
+import StatusList from '../../components/status_list';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourites', defaultMessage: 'Favourites' }
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'favourites', 'items']),
+  loaded: state.getIn(['status_lists', 'favourites', 'loaded']),
+  me: state.getIn(['meta', 'me'])
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScrollToBottom = this.handleScrollToBottom.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  }
+
+  handleScrollToBottom () {
+    this.props.dispatch(expandFavouritedStatuses());
+  }
+
+  render () {
+    const { statusIds, loaded, intl, me } = this.props;
+
+    if (!loaded) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='star' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <StatusList {...this.props} scrollKey='favourited_statuses' onScrollToBottom={this.handleScrollToBottom} />
+      </Column>
+    );
+  }
+
+}
+
+Favourites.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  statusIds: ImmutablePropTypes.list.isRequired,
+  loaded: PropTypes.bool,
+  intl: PropTypes.object.isRequired,
+  me: PropTypes.number.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Favourites));
diff --git a/app/javascript/mastodon/features/favourites/index.js b/app/javascript/mastodon/features/favourites/index.js
new file mode 100644
index 000000000..c916aa176
--- /dev/null
+++ b/app/javascript/mastodon/features/favourites/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchFavourites } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', Number(props.params.statusId)])
+});
+
+class Favourites extends ImmutablePureComponent {
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavourites(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchFavourites(Number(nextProps.params.statusId)));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='favourites'>
+          <div className='scrollable'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Favourites.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Favourites);
diff --git a/app/javascript/mastodon/features/follow_requests/components/account_authorize.js b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..9fe464628
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/components/account_authorize.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from '../../../components/permalink';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import emojify from '../../../emoji';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+  reject: { id: 'follow_request.reject', defaultMessage: 'Reject' }
+});
+
+class AccountAuthorize extends ImmutablePureComponent {
+
+  render () {
+    const { intl, account, onAuthorize, onReject } = this.props;
+    const content = { __html: emojify(account.get('note')) };
+
+    return (
+      <div className='account-authorize__wrapper'>
+        <div className='account-authorize'>
+          <Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
+            <div className='account-authorize__avatar'><Avatar src={account.get('avatar')} staticSrc={account.get('avatar_static')} size={48} /></div>
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='account__header__content' dangerouslySetInnerHTML={content} />
+        </div>
+
+        <div className='account--panel'>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} /></div>
+          <div className='account--panel__button'><IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} /></div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+AccountAuthorize.propTypes = {
+  account: ImmutablePropTypes.map.isRequired,
+  onAuthorize: PropTypes.func.isRequired,
+  onReject: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..da1e5eaa1
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from '../../../actions/accounts';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id)
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+  onAuthorize (account) {
+    dispatch(authorizeFollowRequest(id));
+  },
+
+  onReject (account) {
+    dispatch(rejectFollowRequest(id));
+  }
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/mastodon/features/follow_requests/index.js b/app/javascript/mastodon/features/follow_requests/index.js
new file mode 100644
index 000000000..c88de48c0
--- /dev/null
+++ b/app/javascript/mastodon/features/follow_requests/index.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from '../../actions/accounts';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'follow_requests', 'items'])
+});
+
+class FollowRequests extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchFollowRequests());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowRequests());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='users' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='follow_requests'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountAuthorizeContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+}
+
+FollowRequests.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(FollowRequests));
diff --git a/app/javascript/mastodon/features/followers/index.js b/app/javascript/mastodon/features/followers/index.js
new file mode 100644
index 000000000..8a1105b55
--- /dev/null
+++ b/app/javascript/mastodon/features/followers/index.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'followers', Number(props.params.accountId), 'items'])
+});
+
+class Followers extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchFollowers(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchFollowers(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandFollowers(Number(this.props.params.accountId)));
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='followers'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='followers'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              <LoadMore onClick={this.handleLoadMore} />
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Followers.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Followers);
diff --git a/app/javascript/mastodon/features/following/index.js b/app/javascript/mastodon/features/following/index.js
new file mode 100644
index 000000000..f181fe727
--- /dev/null
+++ b/app/javascript/mastodon/features/following/index.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing
+} from '../../actions/accounts';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import LoadMore from '../../components/load_more';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'following', Number(props.params.accountId), 'items'])
+});
+
+class Following extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(Number(this.props.params.accountId)));
+    this.props.dispatch(fetchFollowing(Number(this.props.params.accountId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(Number(nextProps.params.accountId)));
+      this.props.dispatch(fetchFollowing(Number(nextProps.params.accountId)));
+    }
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandFollowing(Number(this.props.params.accountId)));
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='following'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <div className='following'>
+              <HeaderContainer accountId={this.props.params.accountId} />
+              {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+              <LoadMore onClick={this.handleLoadMore} />
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Following.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Following);
diff --git a/app/javascript/mastodon/features/generic_not_found/index.js b/app/javascript/mastodon/features/generic_not_found/index.js
new file mode 100644
index 000000000..0290be47f
--- /dev/null
+++ b/app/javascript/mastodon/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import MissingIndicator from '../../components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
new file mode 100644
index 000000000..6bdff2fba
--- /dev/null
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import Column from '../ui/components/column';
+import ColumnLink from '../ui/components/column_link';
+import ColumnSubheading from '../ui/components/column_subheading';
+import { Link } from 'react-router';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  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'},
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' }
+});
+
+const mapStateToProps = state => ({
+  me: state.getIn(['accounts', state.getIn(['meta', 'me'])])
+});
+
+class GettingStarted extends ImmutablePureComponent {
+
+  render () {
+    const { intl, me } = this.props;
+
+    let followRequests = '';
+
+    if (me.get('locked')) {
+      followRequests = <ColumnLink icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />;
+    }
+
+    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' />
+          <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' />
+          <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
+        </div>
+
+        <div className='scrollable optionally-scrollable' style={{ display: 'flex', flexDirection: 'column' }}>
+          <div className='static-content getting-started'>
+            <p><FormattedMessage id='getting_started.open_source_notice' defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}. {apps}.' values={{ github: <a href="https://github.com/tootsuite/mastodon" target="_blank">tootsuite/mastodon</a>, apps: <a href="https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md" target="_blank"><FormattedMessage id='getting_started.apps' defaultMessage='Various apps are available' /></a> }} /></p>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+}
+
+GettingStarted.propTypes = {
+  intl: PropTypes.object.isRequired,
+  me: ImmutablePropTypes.map.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(GettingStarted));
diff --git a/app/javascript/mastodon/features/hashtag_timeline/index.js b/app/javascript/mastodon/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..f5134decf
--- /dev/null
+++ b/app/javascript/mastodon/features/hashtag_timeline/index.js
@@ -0,0 +1,90 @@
+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 {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines
+} from '../../actions/timelines';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import { FormattedMessage } from 'react-intl';
+import createStream from '../../stream';
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'tag', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+class HashtagTimeline extends React.PureComponent {
+
+  _subscribe (dispatch, id) {
+    const { streamingAPIBaseURL, accessToken } = this.props;
+
+    this.subscription = createStream(streamingAPIBaseURL, accessToken, `hashtag&tag=${id}`, {
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('tag', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  _unsubscribe () {
+    if (typeof this.subscription !== 'undefined') {
+      this.subscription.close();
+      this.subscription = null;
+    }
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(refreshTimeline('tag', id));
+    this._subscribe(dispatch, id);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.id !== this.props.params.id) {
+      this.props.dispatch(refreshTimeline('tag', nextProps.params.id));
+      this._unsubscribe();
+      this._subscribe(this.props.dispatch, nextProps.params.id);
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  render () {
+    const { id, hasUnread } = this.props.params;
+
+    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>
+    );
+  }
+
+}
+
+HashtagTimeline.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(HashtagTimeline);
diff --git a/app/javascript/mastodon/features/home_timeline/components/column_settings.js b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..460221fc3
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/column_settings.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from '../../notifications/components/setting_toggle';
+import SettingText from './setting_text';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+  settings: { id: 'home.settings', defaultMessage: 'Column settings' }
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  render () {
+    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 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>
+      </ColumnCollapsable>
+    );
+  }
+
+}
+
+ColumnSettings.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onSave: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+}
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/components/setting_text.js b/app/javascript/mastodon/features/home_timeline/components/setting_text.js
new file mode 100644
index 000000000..dfa2939b7
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/components/setting_text.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+class SettingText extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleChange = this.handleChange.bind(this);
+  }
+
+  handleChange (e) {
+    this.props.onChange(this.props.settingKey, e.target.value)
+  }
+
+  render () {
+    const { settings, settingKey, label } = this.props;
+
+    return (
+      <input
+        className='setting-text'
+        value={settings.getIn(settingKey)}
+        onChange={this.handleChange}
+        placeholder={label}
+      />
+    );
+  }
+
+}
+
+SettingText.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: PropTypes.array.isRequired,
+  label: PropTypes.string.isRequired,
+  onChange: PropTypes.func.isRequired
+};
+
+export default SettingText;
diff --git a/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..3b3ce19bc
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'home'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['home', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/home_timeline/index.js b/app/javascript/mastodon/features/home_timeline/index.js
new file mode 100644
index 000000000..d7c438122
--- /dev/null
+++ b/app/javascript/mastodon/features/home_timeline/index.js
@@ -0,0 +1,38 @@
+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 { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0
+});
+
+class HomeTimeline extends React.PureComponent {
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    return (
+      <Column icon='home' active={hasUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
+        <StatusListContainer {...this.props} scrollKey='home_timeline' type='home' emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage="You aren't following anyone yet. Visit {public} or use search to get started and meet other users." values={{ public: <Link to='/timelines/public'><FormattedMessage id='empty_column.home.public_timeline' defaultMessage='the public timeline' /></Link> }} />} />
+      </Column>
+    );
+  }
+
+}
+
+HomeTimeline.propTypes = {
+  intl: PropTypes.object.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(HomeTimeline));
diff --git a/app/javascript/mastodon/features/mutes/index.js b/app/javascript/mastodon/features/mutes/index.js
new file mode 100644
index 000000000..884b3b3e7
--- /dev/null
+++ b/app/javascript/mastodon/features/mutes/index.js
@@ -0,0 +1,75 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import AccountContainer from '../../containers/account_container';
+import { fetchMutes, expandMutes } from '../../actions/mutes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.mutes', defaultMessage: 'Muted users' }
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'mutes', 'items'])
+});
+
+class Mutes extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchMutes());
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight) {
+      this.props.dispatch(expandMutes());
+    }
+  }
+
+  render () {
+    const { intl, accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column icon='volume-off' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollContainer scrollKey='mutes'>
+          <div className='scrollable mutes' onScroll={this.handleScroll}>
+            {accountIds.map(id =>
+              <AccountContainer key={id} id={id} />
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Mutes.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(mapStateToProps)(injectIntl(Mutes));
diff --git a/app/javascript/mastodon/features/notifications/components/clear_column_button.js b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..a948bff46
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/clear_column_button.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  clear: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
+});
+
+class ClearColumnButton extends React.Component {
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <div role='button' title={intl.formatMessage(messages.clear)} className='column-icon column-icon-clear' tabIndex='0' onClick={this.props.onClick}>
+        <i className='fa fa-eraser' />
+      </div>
+    );
+  }
+}
+
+ClearColumnButton.propTypes = {
+  onClick: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ClearColumnButton);
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..7d52b7dcd
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnCollapsable from '../../../components/column_collapsable';
+import SettingToggle from './setting_toggle';
+
+const messages = defineMessages({
+  settings: { id: 'notifications.settings', defaultMessage: 'Column settings' }
+});
+
+class ColumnSettings extends React.PureComponent {
+
+  render () {
+    const { settings, intl, onChange, onSave } = this.props;
+
+    const alertStr = <FormattedMessage id='notifications.column_settings.alert' defaultMessage='Desktop notifications' />;
+    const showStr  = <FormattedMessage id='notifications.column_settings.show' defaultMessage='Show in column' />;
+    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 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>
+      </ColumnCollapsable>
+    );
+  }
+
+}
+
+ColumnSettings.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  onChange: PropTypes.func.isRequired,
+  onSave: PropTypes.func.isRequired,
+  intl: PropTypes.shape({
+    formatMessage: PropTypes.func.isRequired
+  }).isRequired
+};
+
+export default injectIntl(ColumnSettings);
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
new file mode 100644
index 000000000..f54a65747
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusContainer from '../../../containers/status_container';
+import AccountContainer from '../../../containers/account_container';
+import { FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import emojify from '../../../emoji';
+import escapeTextContentForBrowser from 'escape-html';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class Notification extends ImmutablePureComponent {
+
+  renderFollow (account, link) {
+    return (
+      <div className='notification notification-follow'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-user-plus' />
+          </div>
+
+          <FormattedMessage id='notification.follow' defaultMessage='{name} followed you' values={{ name: link }} />
+        </div>
+
+        <AccountContainer id={account.get('id')} withNote={false} />
+      </div>
+    );
+  }
+
+  renderMention (notification) {
+    return <StatusContainer id={notification.get('status')} />;
+  }
+
+  renderFavourite (notification, link) {
+    return (
+      <div className='notification notification-favourite'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-star star-icon'/>
+          </div>
+
+          <FormattedMessage id='notification.favourite' defaultMessage='{name} favourited your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} muted={true} />
+      </div>
+    );
+  }
+
+  renderReblog (notification, link) {
+    return (
+      <div className='notification notification-reblog'>
+        <div className='notification__message'>
+          <div className='notification__favourite-icon-wrapper'>
+            <i className='fa fa-fw fa-retweet' />
+          </div>
+
+          <FormattedMessage id='notification.reblog' defaultMessage='{name} boosted your status' values={{ name: link }} />
+        </div>
+
+        <StatusContainer id={notification.get('status')} muted={true} />
+      </div>
+    );
+  }
+
+  render () { // eslint-disable-line consistent-return
+    const { notification } = this.props;
+    const account          = notification.get('account');
+    const displayName      = account.get('display_name').length > 0 ? account.get('display_name') : account.get('username');
+    const displayNameHTML = { __html: emojify(escapeTextContentForBrowser(displayName)) };
+    const link             = <Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHTML} />;
+
+    switch(notification.get('type')) {
+    case 'follow':
+      return this.renderFollow(account, link);
+    case 'mention':
+      return this.renderMention(notification);
+    case 'favourite':
+      return this.renderFavourite(notification, link);
+    case 'reblog':
+      return this.renderReblog(notification, link);
+    }
+  }
+
+}
+
+Notification.propTypes = {
+  notification: ImmutablePropTypes.map.isRequired
+};
+
+export default Notification;
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..080804a40
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+const SettingToggle = ({ settings, settingKey, label, onChange, htmlFor = '' }) => (
+  <label htmlFor={htmlFor} className='setting-toggle__label'>
+    <Toggle checked={settings.getIn(settingKey)} onChange={(e) => onChange(settingKey, e.target.checked)} />
+    <span className='setting-toggle'>{label}</span>
+  </label>
+);
+
+SettingToggle.propTypes = {
+  settings: ImmutablePropTypes.map.isRequired,
+  settingKey: PropTypes.array.isRequired,
+  label: PropTypes.node.isRequired,
+  onChange: PropTypes.func.isRequired,
+  htmlFor: PropTypes.string
+};
+
+export default SettingToggle;
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..bc24c75e0
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from '../../../actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'notifications'])
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['notifications', ...key], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/mastodon/features/notifications/containers/notification_container.js b/app/javascript/mastodon/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..4ca1b1b7b
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/containers/notification_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import { makeGetNotification } from '../../../selectors';
+import Notification from '../components/notification';
+
+const makeMapStateToProps = () => {
+  const getNotification = makeGetNotification();
+
+  const mapStateToProps = (state, props) => ({
+    notification: getNotification(state, props.notification, props.accountId)
+  });
+
+  return mapStateToProps;
+};
+
+export default connect(makeMapStateToProps)(Notification);
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
new file mode 100644
index 000000000..989013cc7
--- /dev/null
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -0,0 +1,143 @@
+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 { expandNotifications, clearNotifications, scrollTopNotifications } from '../../actions/notifications';
+import NotificationContainer from './containers/notification_container';
+import { ScrollContainer } from 'react-router-scroll';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { createSelector } from 'reselect';
+import Immutable from 'immutable';
+import LoadMore from '../../components/load_more';
+import ClearColumnButton from './components/clear_column_button';
+import { openModal } from '../../actions/modal';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  clearMessage: { id: 'notifications.clear_confirmation', defaultMessage: 'Are you sure you want to permanently clear all your notifications?' },
+  clearConfirm: { id: 'notifications.clear', defaultMessage: 'Clear notifications' }
+});
+
+const getNotifications = createSelector([
+  state => Immutable.List(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  state => state.getIn(['notifications', 'items'])
+], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
+
+const mapStateToProps = state => ({
+  notifications: getNotifications(state),
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0
+});
+
+class Notifications extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleScroll = this.handleScroll.bind(this);
+    this.handleLoadMore = this.handleLoadMore.bind(this);
+    this.handleClear = this.handleClear.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleScroll (e) {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+    this._oldScrollPosition = scrollHeight - scrollTop;
+
+    if (250 > offset && !this.props.isLoading) {
+      this.props.dispatch(expandNotifications());
+    } else if (scrollTop < 100) {
+      this.props.dispatch(scrollTopNotifications(true));
+    } else {
+      this.props.dispatch(scrollTopNotifications(false));
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.node.scrollTop > 0 && (prevProps.notifications.size < this.props.notifications.size && prevProps.notifications.first() !== this.props.notifications.first() && !!this._oldScrollPosition)) {
+      this.node.scrollTop = this.node.scrollHeight - this._oldScrollPosition;
+    }
+  }
+
+  handleLoadMore (e) {
+    e.preventDefault();
+    this.props.dispatch(expandNotifications());
+  }
+
+  handleClear () {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(clearNotifications())
+    }));
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread } = this.props;
+
+    let loadMore       = '';
+    let scrollableArea = '';
+    let unread         = '';
+
+    if (!isLoading && notifications.size > 0) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    if (isUnread) {
+      unread = <div className='notifications__unread-indicator' />;
+    }
+
+    if (isLoading || notifications.size > 0) {
+      scrollableArea = (
+        <div className='scrollable' onScroll={this.handleScroll} ref={this.setRef}>
+          {unread}
+
+          <div>
+            {notifications.map(item => <NotificationContainer key={item.get('id')} notification={item} accountId={item.get('account')} />)}
+            {loadMore}
+          </div>
+        </div>
+      );
+    } else {
+      scrollableArea = (
+        <div className='empty-column-indicator' ref={this.setRef}>
+          <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />
+        </div>
+      );
+    }
+
+    return (
+      <Column icon='bell' active={isUnread} heading={intl.formatMessage(messages.title)}>
+        <ColumnSettingsContainer />
+        <ClearColumnButton onClick={this.handleClear} />
+        <ScrollContainer scrollKey='notifications' shouldUpdateScroll={shouldUpdateScroll}>
+          {scrollableArea}
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Notifications.propTypes = {
+  notifications: ImmutablePropTypes.list.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  shouldUpdateScroll: PropTypes.func,
+  intl: PropTypes.object.isRequired,
+  isLoading: PropTypes.bool,
+  isUnread: PropTypes.bool
+};
+
+Notifications.defaultProps = {
+  trackScroll: true
+};
+
+export default connect(mapStateToProps)(injectIntl(Notifications));
diff --git a/app/javascript/mastodon/features/public_timeline/index.js b/app/javascript/mastodon/features/public_timeline/index.js
new file mode 100644
index 000000000..3b270c62f
--- /dev/null
+++ b/app/javascript/mastodon/features/public_timeline/index.js
@@ -0,0 +1,96 @@
+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 {
+  refreshTimeline,
+  updateTimeline,
+  deleteFromTimelines,
+  connectTimeline,
+  disconnectTimeline
+} from '../../actions/timelines';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import createStream from '../../stream';
+
+const messages = defineMessages({
+  title: { id: 'column.public', defaultMessage: 'Federated timeline' }
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+  streamingAPIBaseURL: state.getIn(['meta', 'streaming_api_base_url']),
+  accessToken: state.getIn(['meta', 'access_token'])
+});
+
+let subscription;
+
+class PublicTimeline extends React.PureComponent {
+
+  componentDidMount () {
+    const { dispatch, streamingAPIBaseURL, accessToken } = this.props;
+
+    dispatch(refreshTimeline('public'));
+
+    if (typeof subscription !== 'undefined') {
+      return;
+    }
+
+    subscription = createStream(streamingAPIBaseURL, accessToken, 'public', {
+
+      connected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      reconnected () {
+        dispatch(connectTimeline('public'));
+      },
+
+      disconnected () {
+        dispatch(disconnectTimeline('public'));
+      },
+
+      received (data) {
+        switch(data.event) {
+        case 'update':
+          dispatch(updateTimeline('public', JSON.parse(data.payload)));
+          break;
+        case 'delete':
+          dispatch(deleteFromTimelines(data.payload));
+          break;
+        }
+      }
+
+    });
+  }
+
+  componentWillUnmount () {
+    // if (typeof subscription !== 'undefined') {
+    //   subscription.close();
+    //   subscription = null;
+    // }
+  }
+
+  render () {
+    const { intl, hasUnread } = this.props;
+
+    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>
+    );
+  }
+
+}
+
+PublicTimeline.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  streamingAPIBaseURL: PropTypes.string.isRequired,
+  accessToken: PropTypes.string.isRequired,
+  hasUnread: PropTypes.bool
+};
+
+export default connect(mapStateToProps)(injectIntl(PublicTimeline));
diff --git a/app/javascript/mastodon/features/reblogs/index.js b/app/javascript/mastodon/features/reblogs/index.js
new file mode 100644
index 000000000..48df8451d
--- /dev/null
+++ b/app/javascript/mastodon/features/reblogs/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from '../../components/loading_indicator';
+import { fetchReblogs } from '../../actions/interactions';
+import { ScrollContainer } from 'react-router-scroll';
+import AccountContainer from '../../containers/account_container';
+import Column from '../ui/components/column';
+import ColumnBackButton from '../../components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', Number(props.params.statusId)])
+});
+
+class Reblogs extends ImmutablePureComponent {
+
+  componentWillMount () {
+    this.props.dispatch(fetchReblogs(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchReblogs(Number(nextProps.params.statusId)));
+    }
+  }
+
+  render () {
+    const { accountIds } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='reblogs'>
+          <div className='scrollable reblogs'>
+            {accountIds.map(id => <AccountContainer key={id} id={id} withNote={false} />)}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Reblogs.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  accountIds: ImmutablePropTypes.list
+};
+
+export default connect(mapStateToProps)(Reblogs);
diff --git a/app/javascript/mastodon/features/report/components/status_check_box.js b/app/javascript/mastodon/features/report/components/status_check_box.js
new file mode 100644
index 000000000..85f792479
--- /dev/null
+++ b/app/javascript/mastodon/features/report/components/status_check_box.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import emojify from '../../../emoji';
+import Toggle from 'react-toggle';
+
+class StatusCheckBox extends React.PureComponent {
+
+  render () {
+    const { status, checked, onToggle, disabled } = this.props;
+    const content = { __html: emojify(status.get('content')) };
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    return (
+      <div className='status-check-box'>
+        <div
+          className='status__content'
+          dangerouslySetInnerHTML={content}
+        />
+
+        <div className='status-check-box-toggle'>
+          <Toggle checked={checked} onChange={onToggle} disabled={disabled} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+StatusCheckBox.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  checked: PropTypes.bool,
+  onToggle: PropTypes.func.isRequired,
+  disabled: PropTypes.bool
+};
+
+export default StatusCheckBox;
diff --git a/app/javascript/mastodon/features/report/containers/status_check_box_container.js b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..67ce9d9f3
--- /dev/null
+++ b/app/javascript/mastodon/features/report/containers/status_check_box_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+import StatusCheckBox from '../components/status_check_box';
+import { toggleStatusReport } from '../../../actions/reports';
+import Immutable from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+  status: state.getIn(['statuses', id]),
+  checked: state.getIn(['reports', 'new', 'status_ids'], Immutable.Set()).includes(id)
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onToggle (e) {
+    dispatch(toggleStatusReport(id, e.target.checked));
+  }
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(StatusCheckBox);
diff --git a/app/javascript/mastodon/features/report/index.js b/app/javascript/mastodon/features/report/index.js
new file mode 100644
index 000000000..661fffe56
--- /dev/null
+++ b/app/javascript/mastodon/features/report/index.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { cancelReport, changeReportComment, submitReport } from '../../actions/reports';
+import { fetchAccountTimeline } from '../../actions/accounts';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from '../ui/components/column';
+import Button from '../../components/button';
+import { makeGetAccount } from '../../selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from './containers/status_check_box_container';
+import Immutable from 'immutable';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+
+const messages = defineMessages({
+  heading: { id: 'report.heading', defaultMessage: 'New report' },
+  placeholder: { id: 'report.placeholder', defaultMessage: 'Additional comments' },
+  submit: { id: 'report.submit', defaultMessage: 'Submit' }
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => {
+    const accountId = state.getIn(['reports', 'new', 'account_id']);
+
+    return {
+      isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+      account: getAccount(state, accountId),
+      comment: state.getIn(['reports', 'new', 'comment']),
+      statusIds: Immutable.OrderedSet(state.getIn(['timelines', 'accounts_timelines', accountId, 'items'])).union(state.getIn(['reports', 'new', 'status_ids']))
+    };
+  };
+
+  return mapStateToProps;
+};
+
+class Report extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleCommentChange = this.handleCommentChange.bind(this);
+    this.handleSubmit = this.handleSubmit.bind(this);
+  }
+
+  componentWillMount () {
+    if (!this.props.account) {
+      this.context.router.replace('/');
+    }
+  }
+
+  componentDidMount () {
+    if (!this.props.account) {
+      return;
+    }
+
+    this.props.dispatch(fetchAccountTimeline(this.props.account.get('id')));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(fetchAccountTimeline(nextProps.account.get('id')));
+    }
+  }
+
+  handleCommentChange (e) {
+    this.props.dispatch(changeReportComment(e.target.value));
+  }
+
+  handleSubmit () {
+    this.props.dispatch(submitReport());
+    this.context.router.replace('/');
+  }
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    return (
+      <Column heading={intl.formatMessage(messages.heading)} icon='flag'>
+        <ColumnBackButtonSlim />
+
+        <div className='report scrollable'>
+          <div className='report__target'>
+            <FormattedMessage id='report.target' defaultMessage='Reporting' />
+            <strong>{account.get('acct')}</strong>
+          </div>
+
+          <div className='scrollable report__statuses'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+
+          <div className='report__textarea-wrapper'>
+            <textarea
+              className='report__textarea'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              disabled={isSubmitting}
+            />
+
+            <div className='report__submit'>
+              <div className='report__submit-button'><Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} /></div>
+            </div>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+
+}
+
+Report.contextTypes = {
+  router: PropTypes.object
+};
+
+Report.propTypes = {
+  isSubmitting: PropTypes.bool,
+  account: ImmutablePropTypes.map,
+  statusIds: ImmutablePropTypes.orderedSet.isRequired,
+  comment: PropTypes.string.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default connect(makeMapStateToProps)(injectIntl(Report));
diff --git a/app/javascript/mastodon/features/status/components/action_bar.js b/app/javascript/mastodon/features/status/components/action_bar.js
new file mode 100644
index 000000000..384b47c8f
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/action_bar.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from '../../../components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenu from '../../../components/dropdown_menu';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  report: { id: 'status.report', defaultMessage: 'Report @{name}' }
+});
+
+class ActionBar extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  handleReplyClick () {
+    this.props.onReply(this.props.status);
+  }
+
+  handleReblogClick (e) {
+    this.props.onReblog(this.props.status, e);
+  }
+
+  handleFavouriteClick () {
+    this.props.onFavourite(this.props.status);
+  }
+
+  handleDeleteClick () {
+    this.props.onDelete(this.props.status);
+  }
+
+  handleMentionClick () {
+    this.props.onMention(this.props.status.get('account'), this.context.router);
+  }
+
+  handleReport () {
+    this.props.onReport(this.props.status);
+    this.context.router.push('/report');
+  }
+
+  render () {
+    const { status, me, intl } = this.props;
+
+    let menu = [];
+
+    if (me === status.getIn(['account', 'id'])) {
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+    }
+
+    let reblogIcon = 'retweet';
+    if (status.get('visibility') === 'direct') reblogIcon = 'envelope';
+    else if (status.get('visibility') === 'private') reblogIcon = 'lock';
+
+    let reblog_disabled = (status.get('visibility') === 'direct' || status.get('visibility') === 'private');
+
+    return (
+      <div className='detailed-status__action-bar'>
+        <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.reply)} icon={status.get('in_reply_to_id', null) === null ? 'reply' : 'reply-all'} onClick={this.handleReplyClick} /></div>
+        <div className='detailed-status__button'><IconButton disabled={reblog_disabled} active={status.get('reblogged')} title={reblog_disabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(messages.reblog)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton animate={true} active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        <div className='detailed-status__button'><DropdownMenu size={18} icon='ellipsis-h' items={menu} direction="left" ariaLabel="More" /></div>
+      </div>
+    );
+  }
+
+}
+
+ActionBar.contextTypes = {
+  router: PropTypes.object
+};
+
+ActionBar.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReply: PropTypes.func.isRequired,
+  onReblog: PropTypes.func.isRequired,
+  onFavourite: PropTypes.func.isRequired,
+  onDelete: PropTypes.func.isRequired,
+  onMention: PropTypes.func.isRequired,
+  onReport: PropTypes.func,
+  me: PropTypes.number.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ActionBar);
diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js
new file mode 100644
index 000000000..9e7d4f884
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/card.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const hostStyle = {
+  display: 'block',
+  marginTop: '5px',
+  fontSize: '13px'
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+class Card extends React.PureComponent {
+
+  renderLink () {
+    const { card } = this.props;
+
+    let image    = '';
+    let provider = card.get('provider_name');
+
+    if (card.get('image')) {
+      image = (
+        <div className='status-card__image'>
+          <img src={card.get('image')} alt={card.get('title')} className='status-card__image-image' />
+        </div>
+      );
+    }
+
+    if (provider.length < 1) {
+      provider = getHostname(card.get('url'))
+    }
+
+    return (
+      <a href={card.get('url')} className='status-card' target='_blank' rel='noopener'>
+        {image}
+
+        <div className='status-card__content'>
+          <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>
+          <p className='status-card__description'>{(card.get('description') || '').substring(0, 50)}</p>
+          <span className='status-card__host' style={hostStyle}>{provider}</span>
+        </div>
+      </a>
+    );
+  }
+
+  renderPhoto () {
+    const { card } = this.props;
+
+    return (
+      <a href={card.get('url')} className='status-card-photo' target='_blank' rel='noopener'>
+        <img src={card.get('url')} alt={card.get('title')} width={card.get('width')} height={card.get('height')} />
+      </a>
+    );
+  }
+
+  renderVideo () {
+    const { card } = this.props;
+    const content  = { __html: card.get('html') };
+
+    return (
+      <div
+        className='status-card-video'
+        dangerouslySetInnerHTML={content}
+      />
+    );
+  }
+
+  render () {
+    const { card } = this.props;
+
+    if (card === null) {
+      return null;
+    }
+
+    switch(card.get('type')) {
+    case 'link':
+      return this.renderLink();
+    case 'photo':
+      return this.renderPhoto();
+    case 'video':
+      return this.renderVideo();
+    case 'rich':
+    default:
+      return null;
+    }
+  }
+}
+
+Card.propTypes = {
+  card: ImmutablePropTypes.map
+};
+
+export default Card;
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
new file mode 100644
index 000000000..913a186b9
--- /dev/null
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import StatusContent from '../../../components/status_content';
+import MediaGallery from '../../../components/media_gallery';
+import VideoPlayer from '../../../components/video_player';
+import AttachmentList from '../../../components/attachment_list';
+import { Link } from 'react-router';
+import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+class DetailedStatus extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+
+    let media           = '';
+    let applicationLink = '';
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+        media = <AttachmentList media={status.get('media_attachments')} />;
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        media = <VideoPlayer sensitive={status.get('sensitive')} media={status.getIn(['media_attachments', 0])} width={300} height={150} onOpenVideo={this.props.onOpenVideo} autoplay />;
+      } else {
+        media = <MediaGallery sensitive={status.get('sensitive')} media={status.get('media_attachments')} height={300} onOpenMedia={this.props.onOpenMedia} autoPlayGif={this.props.autoPlayGif} />;
+      }
+    } else if (status.get('spoiler_text').length === 0) {
+      media = <CardContainer statusId={status.get('id')} />;
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
+    }
+
+    return (
+      <div className='detailed-status'>
+        <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'><Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} /></div>
+          <DisplayName account={status.get('account')} />
+        </a>
+
+        <StatusContent status={status} />
+
+        {media}
+
+        <div className='detailed-status__meta'>
+          <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
+            <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
+          </a>{applicationLink} · <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+            <i className='fa fa-retweet' />
+            <span className='detailed-status__reblogs'>
+              <FormattedNumber value={status.get('reblogs_count')} />
+            </span>
+          </Link> · <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+            <i className='fa fa-star' />
+            <span className='detailed-status__favorites'>
+              <FormattedNumber value={status.get('favourites_count')} />
+            </span>
+          </Link>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+DetailedStatus.contextTypes = {
+  router: PropTypes.object
+};
+
+DetailedStatus.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onOpenMedia: PropTypes.func.isRequired,
+  onOpenVideo: PropTypes.func.isRequired,
+  autoPlayGif: PropTypes.bool,
+};
+
+export default DetailedStatus;
diff --git a/app/javascript/mastodon/features/status/containers/card_container.js b/app/javascript/mastodon/features/status/containers/card_container.js
new file mode 100644
index 000000000..5c8bfeec2
--- /dev/null
+++ b/app/javascript/mastodon/features/status/containers/card_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import Card from '../components/card';
+
+const mapStateToProps = (state, { statusId }) => ({
+  card: state.getIn(['cards', statusId], null)
+});
+
+export default connect(mapStateToProps)(Card);
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
new file mode 100644
index 000000000..2e8c9e56a
--- /dev/null
+++ b/app/javascript/mastodon/features/status/index.js
@@ -0,0 +1,199 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from '../../actions/statuses';
+import Immutable from 'immutable';
+import EmbeddedStatus from '../../components/status';
+import MissingIndicator from '../../components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from '../ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  reblog,
+  unreblog
+} from '../../actions/interactions';
+import {
+  replyCompose,
+  mentionCompose
+} from '../../actions/compose';
+import { deleteStatus } from '../../actions/statuses';
+import { initReport } from '../../actions/reports';
+import {
+  makeGetStatus,
+  getStatusAncestors,
+  getStatusDescendants
+} from '../../selectors';
+import { ScrollContainer } from 'react-router-scroll';
+import ColumnBackButton from '../../components/column_back_button';
+import StatusContainer from '../../containers/status_container';
+import { openModal } from '../../actions/modal';
+import { isMobile } from '../../is_mobile'
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
+  deleteMessage: { id: 'confirmations.delete.message', defaultMessage: 'Are you sure you want to delete this status?' }
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, Number(props.params.statusId)),
+    ancestorsIds: state.getIn(['timelines', 'ancestors', Number(props.params.statusId)]),
+    descendantsIds: state.getIn(['timelines', 'descendants', Number(props.params.statusId)]),
+    me: state.getIn(['meta', 'me']),
+    boostModal: state.getIn(['meta', 'boost_modal']),
+    autoPlayGif: state.getIn(['meta', 'auto_play_gif'])
+  });
+
+  return mapStateToProps;
+};
+
+class Status extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleFavouriteClick = this.handleFavouriteClick.bind(this);
+    this.handleReplyClick = this.handleReplyClick.bind(this);
+    this.handleModalReblog = this.handleModalReblog.bind(this);
+    this.handleReblogClick = this.handleReblogClick.bind(this);
+    this.handleDeleteClick = this.handleDeleteClick.bind(this);
+    this.handleMentionClick = this.handleMentionClick.bind(this);
+    this.handleOpenMedia = this.handleOpenMedia.bind(this);
+    this.handleOpenVideo = this.handleOpenVideo.bind(this);
+    this.handleReport = this.handleReport.bind(this);
+  }
+
+  componentWillMount () {
+    this.props.dispatch(fetchStatus(Number(this.props.params.statusId)));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchStatus(Number(nextProps.params.statusId)));
+    }
+  }
+
+  handleFavouriteClick (status) {
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      this.props.dispatch(favourite(status));
+    }
+  }
+
+  handleReplyClick (status) {
+    this.props.dispatch(replyCompose(status, this.context.router));
+  }
+
+  handleModalReblog (status) {
+    this.props.dispatch(reblog(status));
+  }
+
+  handleReblogClick (status, e) {
+    if (status.get('reblogged')) {
+      this.props.dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !this.props.boostModal) {
+        this.handleModalReblog(status);
+      } else {
+        this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+      }
+    }
+  }
+
+  handleDeleteClick (status) {
+    const { dispatch, intl } = this.props;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.deleteMessage),
+      confirm: intl.formatMessage(messages.deleteConfirm),
+      onConfirm: () => dispatch(deleteStatus(status.get('id')))
+    }));
+  }
+
+  handleMentionClick (account, router) {
+    this.props.dispatch(mentionCompose(account, router));
+  }
+
+  handleOpenMedia (media, index) {
+    this.props.dispatch(openModal('MEDIA', { media, index }));
+  }
+
+  handleOpenVideo (media, time) {
+    this.props.dispatch(openModal('VIDEO', { media, time }));
+  }
+
+  handleReport (status) {
+    this.props.dispatch(initReport(status.get('account'), status));
+  }
+
+  renderChildren (list) {
+    return list.map(id => <StatusContainer key={id} id={id} />);
+  }
+
+  render () {
+    let ancestors, descendants;
+    const { status, ancestorsIds, descendantsIds, me, autoPlayGif } = this.props;
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    const account = status.get('account');
+
+    if (ancestorsIds && ancestorsIds.size > 0) {
+      ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
+    }
+
+    if (descendantsIds && descendantsIds.size > 0) {
+      descendants = <div>{this.renderChildren(descendantsIds)}</div>;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='thread'>
+          <div className='scrollable detailed-status__wrapper'>
+            {ancestors}
+
+            <DetailedStatus status={status} autoPlayGif={autoPlayGif} me={me} onOpenVideo={this.handleOpenVideo} onOpenMedia={this.handleOpenMedia} />
+            <ActionBar status={status} me={me} onReply={this.handleReplyClick} onFavourite={this.handleFavouriteClick} onReblog={this.handleReblogClick} onDelete={this.handleDeleteClick} onMention={this.handleMentionClick} onReport={this.handleReport} />
+
+            {descendants}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
+
+Status.contextTypes = {
+  router: PropTypes.object
+};
+
+Status.propTypes = {
+  params: PropTypes.object.isRequired,
+  dispatch: PropTypes.func.isRequired,
+  status: ImmutablePropTypes.map,
+  ancestorsIds: ImmutablePropTypes.list,
+  descendantsIds: ImmutablePropTypes.list,
+  me: PropTypes.number,
+  boostModal: PropTypes.bool,
+  autoPlayGif: PropTypes.bool,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(connect(makeMapStateToProps)(Status));
diff --git a/app/javascript/mastodon/features/ui/components/boost_modal.js b/app/javascript/mastodon/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..d6000fe4e
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/boost_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import Button from '../../../components/button';
+import StatusContent from '../../../components/status_content';
+import Avatar from '../../../components/avatar';
+import RelativeTimestamp from '../../../components/relative_timestamp';
+import DisplayName from '../../../components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' }
+});
+
+class BoostModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleReblog = this.handleReblog.bind(this);
+    this.handleAccountClick = this.handleAccountClick.bind(this);
+  }
+
+  handleReblog() {
+    this.props.onReblog(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick (e) {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      this.context.router.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal boost-modal'>
+        <div className='boost-modal__container'>
+          <div className='status light'>
+            <div className='boost-modal__status-header'>
+              <div className='boost-modal__status-time'>
+                <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
+              </div>
+
+              <a onClick={this.handleAccountClick} href={status.getIn(['account', 'url'])} className='status__display-name'>
+                <div className='status__avatar'>
+                  <Avatar src={status.getIn(['account', 'avatar'])} staticSrc={status.getIn(['account', 'avatar_static'])} size={48} />
+                </div>
+
+                <DisplayName account={status.get('account')} />
+              </a>
+            </div>
+
+            <StatusContent status={status} />
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div><FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <i className='fa fa-retweet' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.reblog)} onClick={this.handleReblog} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+BoostModal.contextTypes = {
+  router: PropTypes.object
+};
+
+BoostModal.propTypes = {
+  status: ImmutablePropTypes.map.isRequired,
+  onReblog: PropTypes.func.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(BoostModal);
diff --git a/app/javascript/mastodon/features/ui/components/column.js b/app/javascript/mastodon/features/ui/components/column.js
new file mode 100644
index 000000000..fcb197573
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+
+const easingOutQuint = (x, t, b, c, d) => c*((t=t/d-1)*t*t*t*t + 1) + b;
+
+const scrollTop = (node) => {
+  const startTime = Date.now();
+  const offset    = node.scrollTop;
+  const targetY   = -offset;
+  const duration  = 1000;
+  let interrupt   = false;
+
+  const step = () => {
+    const elapsed    = Date.now() - startTime;
+    const percentage = elapsed / duration;
+
+    if (percentage > 1 || interrupt) {
+      return;
+    }
+
+    node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+    requestAnimationFrame(step);
+  };
+
+  step();
+
+  return () => {
+    interrupt = true;
+  };
+};
+
+class Column extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleHeaderClick = this.handleHeaderClick.bind(this);
+    this.handleWheel = this.handleWheel.bind(this);
+    this.setRef = this.setRef.bind(this);
+  }
+
+  handleHeaderClick () {
+    const scrollable = this.node.querySelector('.scrollable');
+    if (!scrollable) {
+      return;
+    }
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+  handleWheel () {
+    if (typeof this._interruptScrollAnimation !== 'undefined') {
+      this._interruptScrollAnimation();
+    }
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    const { heading, icon, children, active, hideHeadingOnMobile } = this.props;
+
+    let columnHeaderId = null
+    let header = '';
+
+    if (heading) {
+      columnHeaderId = heading.replace(/ /g, '-')
+      header = <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} hideOnMobile={hideHeadingOnMobile} columnHeaderId={columnHeaderId}/>;
+    }
+    return (
+      <div
+        ref={this.setRef}
+        role='region'
+        aria-labelledby={columnHeaderId}
+        className='column'
+        onWheel={this.handleWheel}>
+        {header}
+        {children}
+      </div>
+    );
+  }
+
+}
+
+Column.propTypes = {
+  heading: PropTypes.string,
+  icon: PropTypes.string,
+  children: PropTypes.node,
+  active: PropTypes.bool,
+  hideHeadingOnMobile: PropTypes.bool
+};
+
+export default Column;
diff --git a/app/javascript/mastodon/features/ui/components/column_header.js b/app/javascript/mastodon/features/ui/components/column_header.js
new file mode 100644
index 000000000..2701cd57d
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_header.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types'
+
+class ColumnHeader extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClick();
+  }
+
+  render () {
+    const { type, active, hideOnMobile, columnHeaderId } = this.props;
+
+    let icon = '';
+
+    if (this.props.icon) {
+      icon = <i className={`fa fa-fw fa-${this.props.icon} column-header__icon`} />;
+    }
+
+    return (
+      <div role='button heading' tabIndex='0' className={`column-header ${active ? 'active' : ''} ${hideOnMobile ? 'hidden-on-mobile' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+        {icon}
+        {type}
+      </div>
+    );
+  }
+
+}
+
+ColumnHeader.propTypes = {
+  icon: PropTypes.string,
+  type: PropTypes.string,
+  active: PropTypes.bool,
+  onClick: PropTypes.func,
+  hideOnMobile: PropTypes.bool,
+  columnHeaderId: PropTypes.string
+};
+
+export default ColumnHeader;
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
new file mode 100644
index 000000000..cffe796ba
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router';
+
+const ColumnLink = ({ icon, text, to, href, method, hideOnMobile }) => {
+  if (href) {
+    return (
+      <a href={href} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`} data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  } else {
+    return (
+      <Link to={to} className={`column-link ${hideOnMobile ? 'hidden-on-mobile' : ''}`}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </Link>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  href: PropTypes.string,
+  method: PropTypes.string,
+  hideOnMobile: PropTypes.bool
+};
+
+export default ColumnLink;
diff --git a/app/javascript/mastodon/features/ui/components/column_subheading.js b/app/javascript/mastodon/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/column_subheading.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const ColumnSubheading = ({ text }) => {
+  return (
+    <div className='column-subheading'>
+      {text}
+    </div>
+  );
+};
+
+ColumnSubheading.propTypes = {
+  text: PropTypes.string.isRequired,
+};
+
+export default ColumnSubheading;
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
new file mode 100644
index 000000000..05f9f3fb5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+class ColumnsArea extends React.PureComponent {
+
+  render () {
+    return (
+      <div className='columns-area'>
+        {this.props.children}
+      </div>
+    );
+  }
+
+}
+
+ColumnsArea.propTypes = {
+  children: PropTypes.node
+};
+
+export default ColumnsArea;
diff --git a/app/javascript/mastodon/features/ui/components/confirmation_modal.js b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..499993207
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/confirmation_modal.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Button from '../../../components/button';
+
+class ConfirmationModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleClick = this.handleClick.bind(this);
+    this.handleCancel = this.handleCancel.bind(this);
+  }
+
+  handleClick () {
+    this.props.onClose();
+    this.props.onConfirm();
+  }
+
+  handleCancel (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  render () {
+    const { intl, message, confirm, onConfirm, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div className='confirmation-modal__action-bar'>
+          <div><a href='#' onClick={this.handleCancel}><FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' /></a></div>
+          <Button text={confirm} onClick={this.handleClick} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+ConfirmationModal.propTypes = {
+  message: PropTypes.node.isRequired,
+  confirm: PropTypes.string.isRequired,
+  onClose: PropTypes.func.isRequired,
+  onConfirm: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(ConfirmationModal);
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.js b/app/javascript/mastodon/features/ui/components/media_modal.js
new file mode 100644
index 000000000..a8fb3858a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/media_modal.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import ImageLoader from 'react-imageloader';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class MediaModal extends ImmutablePureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      index: null
+    };
+    this.handleNextClick = this.handleNextClick.bind(this);
+    this.handlePrevClick = this.handlePrevClick.bind(this);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleNextClick () {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size});
+  }
+
+  handlePrevClick () {
+    this.setState({ index: (this.getIndex() - 1) % this.props.media.size});
+  }
+
+  handleKeyUp (e) {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      break;
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  }
+
+  render () {
+    const { media, intl, onClose } = this.props;
+
+    const index = this.getIndex();
+    const attachment = media.get(index);
+    const url = attachment.get('url');
+
+    let leftNav, rightNav, content;
+
+    leftNav = rightNav = content = '';
+
+    if (media.size > 1) {
+      leftNav  = <div role='button' tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick}><i className='fa fa-fw fa-chevron-left' /></div>;
+      rightNav = <div role='button' tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick}><i className='fa fa-fw fa-chevron-right' /></div>;
+    }
+
+    if (attachment.get('type') === 'image') {
+      content = <ImageLoader src={url} imgProps={{ style: { display: 'block' } }} />;
+    } else if (attachment.get('type') === 'gifv') {
+      content = <ExtendedVideoPlayer src={url} muted={true} controls={false} />;
+    }
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        {leftNav}
+
+        <div className='media-modal__content'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          {content}
+        </div>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+}
+
+MediaModal.propTypes = {
+  media: ImmutablePropTypes.list.isRequired,
+  index: PropTypes.number.isRequired,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(MediaModal);
diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js
new file mode 100644
index 000000000..5cde65907
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/modal_root.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import MediaModal from './media_modal';
+import OnboardingModal from './onboarding_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import ConfirmationModal from './confirmation_modal';
+import { TransitionMotion, spring } from 'react-motion';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': MediaModal,
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': VideoModal,
+  'BOOST': BoostModal,
+  'CONFIRM': ConfirmationModal
+};
+
+class ModalRoot extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.type) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  willEnter () {
+    return { opacity: 0, scale: 0.98 };
+  }
+
+  willLeave () {
+    return { opacity: spring(0), scale: spring(0.98) };
+  }
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const items = [];
+
+    if (!!type) {
+      items.push({
+        key: type,
+        data: { type, props },
+        style: { opacity: spring(1), scale: spring(1, { stiffness: 120, damping: 14 }) }
+      });
+    }
+
+    return (
+      <TransitionMotion
+        styles={items}
+        willEnter={this.willEnter}
+        willLeave={this.willLeave}>
+        {interpolatedStyles =>
+          <div className='modal-root'>
+            {interpolatedStyles.map(({ key, data: { type, props }, style }) => {
+              const SpecificComponent = MODAL_COMPONENTS[type];
+
+              return (
+                <div key={key}>
+                  <div role='presentation' className='modal-root__overlay' style={{ opacity: style.opacity }} onClick={onClose} />
+                  <div className='modal-root__container' style={{ opacity: style.opacity, transform: `translateZ(0px) scale(${style.scale})` }}>
+                    <SpecificComponent {...props} onClose={onClose} />
+                  </div>
+                </div>
+              );
+            })}
+          </div>
+        }
+      </TransitionMotion>
+    );
+  }
+
+}
+
+ModalRoot.propTypes = {
+  type: PropTypes.string,
+  props: PropTypes.object,
+  onClose: PropTypes.func.isRequired
+};
+
+export default ModalRoot;
diff --git a/app/javascript/mastodon/features/ui/components/onboarding_modal.js b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..7cdd3527a
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/onboarding_modal.js
@@ -0,0 +1,264 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Permalink from '../../../components/permalink';
+import { TransitionMotion, spring } from 'react-motion';
+import ComposeForm from '../../compose/components/compose_form';
+import Search from '../../compose/components/search';
+import NavigationBar from '../../compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import Immutable from 'immutable';
+
+const messages = defineMessages({
+  home_title: { id: 'column.home', defaultMessage: 'Home' },
+  notifications_title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  local_title: { id: 'column.community', defaultMessage: 'Local timeline' },
+  federated_title: { id: 'column.public', defaultMessage: 'Federated timeline' }
+});
+
+const PageOne = ({ acct, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-one'>
+    <div style={{ flex: '0 0 auto' }}>
+      <div className='onboarding-modal__page-one__elephant-friend' />
+    </div>
+
+    <div>
+      <h1><FormattedMessage id='onboarding.page_one.welcome' defaultMessage='Welcome to Mastodon!' /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' /></p>
+      <p><FormattedMessage id='onboarding.page_one.handle' defaultMessage='You are on {domain}, so your full handle is {handle}' values={{ domain, handle: <strong>{acct}@{domain}</strong> }}/></p>
+    </div>
+  </div>
+);
+
+PageOne.propTypes = {
+  acct: PropTypes.string.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageTwo = ({ me }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+      <ComposeForm
+        text='Awoo! #introductions'
+        suggestions={Immutable.List()}
+        mentionedDomains={[]}
+        spoiler={false}
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onPaste={() => {}}
+        onPickEmoji={() => {}}
+        onChangeSpoilerText={() => {}}
+        onClearSuggestions={() => {}}
+        onFetchSuggestions={() => {}}
+        onSuggestionSelected={() => {}}
+      />
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_two.compose' defaultMessage='Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.' /></p>
+  </div>
+);
+
+PageTwo.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ me, domain }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-three'>
+    <div className='figure non-interactive'>
+      <Search
+        value=''
+        onChange={() => {}}
+        onSubmit={() => {}}
+        onClear={() => {}}
+        onShow={() => {}}
+      />
+
+      <div className='pseudo-drawer'>
+        <NavigationBar account={me} />
+      </div>
+    </div>
+
+    <p><FormattedMessage id='onboarding.page_three.search' defaultMessage='Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.' values={{ illustration: <Permalink to='/timelines/tag/illustration' href='/tags/illustration'>#illustration</Permalink>, introductions: <Permalink to='/timelines/tag/introductions' href='/tags/introductions'>#introductions</Permalink> }}/></p>
+    <p><FormattedMessage id='onboarding.page_three.profile' defaultMessage='Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.' /></p>
+  </div>
+);
+
+PageThree.propTypes = {
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired
+};
+
+const PageFour = ({ domain, intl }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-four'>
+    <div className='onboarding-modal__page-four__columns'>
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='home' type={intl.formatMessage(messages.home_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.home' defaultMessage='The home timeline shows posts from people you follow.'/></p>
+        </div>
+
+        <div>
+          <div className='figure non-interactive'><ColumnHeader icon='bell' type={intl.formatMessage(messages.notifications_title)} /></div>
+          <p><FormattedMessage id='onboarding.page_four.notifications' defaultMessage='The notifications column shows when someone interacts with you.' /></p>
+        </div>
+      </div>
+
+      <div className='row'>
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='users' type={intl.formatMessage(messages.local_title)} /></div>
+        </div>
+
+        <div>
+          <div className='figure non-interactive' style={{ marginBottom: 0 }}><ColumnHeader icon='globe' type={intl.formatMessage(messages.federated_title)} /></div>
+        </div>
+      </div>
+
+      <p><FormattedMessage id='onboarding.page_five.public_timelines' defaultMessage='The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.' values={{ domain }} /></p>
+    </div>
+  </div>
+);
+
+PageFour.propTypes = {
+  domain: PropTypes.string.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+const PageSix = ({ admin, domain }) => {
+  let adminSection = '';
+
+  if (admin) {
+    adminSection = (
+      <p>
+        <FormattedMessage id='onboarding.page_six.admin' defaultMessage="Your instance's admin is {admin}." values={{ admin: <Permalink href={admin.get('url')} to={`/accounts/${admin.get('id')}`}>@{admin.get('acct')}</Permalink> }} />
+        <br />
+        <FormattedMessage id='onboarding.page_six.read_guidelines' defaultMessage="Please read {domain}'s {guidelines}!" values={{domain, guidelines: <a href='/about/more' target='_blank'><FormattedMessage id='onboarding.page_six.guidelines' defaultMessage='community guidelines' /></a> }}/>
+      </p>
+    );
+  }
+
+  return (
+    <div className='onboarding-modal__page onboarding-modal__page-six'>
+      <h1><FormattedMessage id='onboarding.page_six.almost_done' defaultMessage='Almost done...' /></h1>
+      {adminSection}
+      <p><FormattedMessage id='onboarding.page_six.github' defaultMessage='Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ github: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>GitHub</a> }} /></p>
+      <p><FormattedMessage id='onboarding.page_six.apps_available' defaultMessage='There are {apps} available for iOS, Android and other platforms.' values={{ apps: <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' target='_blank' rel='noopener'><FormattedMessage id='onboarding.page_six.various_app' defaultMessage='mobile apps' /></a> }} /></p>
+      <p><em><FormattedMessage id='onboarding.page_six.appetoot' defaultMessage='Bon Appetoot!' /></em></p>
+    </div>
+  );
+};
+
+PageSix.propTypes = {
+  admin: ImmutablePropTypes.map,
+  domain: PropTypes.string.isRequired
+};
+
+const mapStateToProps = state => ({
+  me: state.getIn(['accounts', state.getIn(['meta', 'me'])]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain'])
+});
+
+class OnboardingModal extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      currentIndex: 0
+    };
+    this.handleSkip = this.handleSkip.bind(this);
+    this.handleDot = this.handleDot.bind(this);
+    this.handleNext = this.handleNext.bind(this);
+  }
+
+  handleSkip (e) {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  handleDot (i, e) {
+    e.preventDefault();
+    this.setState({ currentIndex: i });
+  }
+
+  handleNext (maxNum, e) {
+    e.preventDefault();
+
+    if (this.state.currentIndex < maxNum - 1) {
+      this.setState({ currentIndex: this.state.currentIndex + 1 });
+    } else {
+      this.props.onClose();
+    }
+  }
+
+  render () {
+    const { me, admin, domain, intl } = this.props;
+
+    const pages = [
+      <PageOne acct={me.get('acct')} domain={domain} />,
+      <PageTwo me={me} />,
+      <PageThree me={me} domain={domain} />,
+      <PageFour domain={domain} intl={intl} />,
+      <PageSix admin={admin} domain={domain} />
+    ];
+
+    const { currentIndex } = this.state;
+    const hasMore = currentIndex < pages.length - 1;
+
+    let nextOrDoneBtn;
+
+    if(hasMore) {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__next'><FormattedMessage id='onboarding.next' defaultMessage='Next' /></a>;
+    } else {
+      nextOrDoneBtn = <a href='#' onClick={this.handleNext.bind(null, pages.length)} className='onboarding-modal__nav onboarding-modal__done'><FormattedMessage id='onboarding.done' defaultMessage='Done' /></a>;
+    }
+
+    const styles = pages.map((page, i) => ({
+      key: `page-${i}`,
+      style: { opacity: spring(i === currentIndex ? 1 : 0) }
+    }));
+
+    return (
+      <div className='modal-root__modal onboarding-modal'>
+        <TransitionMotion styles={styles}>
+          {interpolatedStyles =>
+            <div className='onboarding-modal__pager'>
+              {pages.map((page, i) =>
+                <div key={`page-${i}`} style={{ opacity: interpolatedStyles[i].style.opacity, pointerEvents: i === currentIndex ? 'auto' : 'none' }}>{page}</div>
+              )}
+            </div>
+          }
+        </TransitionMotion>
+
+        <div className='onboarding-modal__paginator'>
+          <div>
+            <a href='#' className='onboarding-modal__skip' onClick={this.handleSkip}><FormattedMessage id='onboarding.skip' defaultMessage='Skip' /></a>
+          </div>
+
+          <div className='onboarding-modal__dots'>
+            {pages.map((_, i) => <div key={i} onClick={this.handleDot.bind(null, i)} className={`onboarding-modal__dot ${i === currentIndex ? 'active' : ''}`} />)}
+          </div>
+
+          <div>
+            {nextOrDoneBtn}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+OnboardingModal.propTypes = {
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired,
+  me: ImmutablePropTypes.map.isRequired,
+  domain: PropTypes.string.isRequired,
+  admin: ImmutablePropTypes.map
+}
+
+export default connect(mapStateToProps)(injectIntl(OnboardingModal));
diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..b6a30bc11
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { Link } from 'react-router';
+import { FormattedMessage } from 'react-intl';
+
+class TabsBar extends React.Component {
+
+  render () {
+    return (
+      <div className='tabs-bar'>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/statuses/new'><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/timelines/home'><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></Link>
+        <Link className='tabs-bar__link primary' activeClassName='active' to='/notifications'><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></Link>
+
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public/local'><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></Link>
+        <Link className='tabs-bar__link secondary' activeClassName='active' to='/timelines/public'><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></Link>
+
+        <Link className='tabs-bar__link primary' activeClassName='active' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started'><i className='fa fa-fw fa-asterisk' /></Link>
+      </div>
+    );
+  }
+
+}
+
+export default TabsBar;
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
new file mode 100644
index 000000000..c5710ee69
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Motion, spring } from 'react-motion';
+import { FormattedMessage } from 'react-intl';
+
+class UploadArea extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+
+    this.handleKeyUp = this.handleKeyUp.bind(this);
+  }
+
+  handleKeyUp (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    const keyCode = e.keyCode
+    if (this.props.active) {
+      switch(keyCode) {
+      case 27:
+        this.props.onClose();
+        break;
+      }
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <Motion defaultStyle={{ backgroundOpacity: 0, backgroundScale: 0.95 }} style={{ backgroundOpacity: spring(active ? 1 : 0, { stiffness: 150, damping: 15 }), backgroundScale: spring(active ? 1 : 0.95, { stiffness: 200, damping: 3 }) }}>
+        {({ backgroundOpacity, backgroundScale }) =>
+          <div className='upload-area' style={{ visibility: active ? 'visible' : 'hidden', opacity: backgroundOpacity }}>
+            <div className='upload-area__drop'>
+              <div className='upload-area__background' style={{ transform: `translateZ(0) scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
+
+UploadArea.propTypes = {
+  active: PropTypes.bool,
+  onClose: PropTypes.func
+};
+
+export default UploadArea;
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.js b/app/javascript/mastodon/features/ui/components/video_modal.js
new file mode 100644
index 000000000..8e2e4a533
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/video_modal.js
@@ -0,0 +1,40 @@
+import React from 'react';
+import LoadingIndicator from '../../../components/loading_indicator';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from '../../../components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from '../../../components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' }
+});
+
+class VideoModal extends ImmutablePureComponent {
+
+  render () {
+    const { media, intl, time, onClose } = this.props;
+
+    const url = media.get('url');
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div>
+          <div className='media-modal__close'><IconButton title={intl.formatMessage(messages.close)} icon='times' overlay onClick={onClose} /></div>
+          <ExtendedVideoPlayer src={url} muted={false} controls={true} time={time} />
+        </div>
+      </div>
+    );
+  }
+
+}
+
+VideoModal.propTypes = {
+  media: ImmutablePropTypes.map.isRequired,
+  time: PropTypes.number,
+  onClose: PropTypes.func.isRequired,
+  intl: PropTypes.object.isRequired
+};
+
+export default injectIntl(VideoModal);
diff --git a/app/javascript/mastodon/features/ui/containers/loading_bar_container.js b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..6c4e73e38
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/loading_bar_container.js
@@ -0,0 +1,8 @@
+import { connect }    from 'react-redux';
+import LoadingBar from 'react-redux-loading-bar';
+
+const mapStateToProps = (state) => ({
+  loading: state.get('loadingBar')
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..26d77818c
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from '../../../actions/modal';
+import ModalRoot from '../components/modal_root';
+
+const mapStateToProps = state => ({
+  type: state.get('modal').modalType,
+  props: state.get('modal').modalProps
+});
+
+const mapDispatchToProps = dispatch => ({
+  onClose () {
+    dispatch(closeModal());
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ModalRoot);
diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..529ebf6c8
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import {
+  dismissAlert,
+  clearAlerts
+} from '../../../actions/alerts';
+import { getAlerts } from '../../../selectors';
+
+const mapStateToProps = (state, props) => ({
+  notifications: getAlerts(state)
+});
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    }
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/mastodon/features/ui/containers/status_list_container.js b/app/javascript/mastodon/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..1599000b5
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/containers/status_list_container.js
@@ -0,0 +1,74 @@
+import { connect } from 'react-redux';
+import StatusList from '../../../components/status_list';
+import { expandTimeline, scrollTopTimeline } from '../../../actions/timelines';
+import Immutable from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'react-decoration';
+
+const makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], Immutable.Map()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], Immutable.List()),
+  (state)           => state.get('statuses'),
+  (state)           => state.getIn(['meta', 'me'])
+], (columnSettings, statusIds, statuses, me) => statusIds.filter(id => {
+  const statusForId = statuses.get(id);
+  let showStatus    = true;
+
+  if (columnSettings.getIn(['shows', 'reblog']) === false) {
+    showStatus = showStatus && statusForId.get('reblog') === null;
+  }
+
+  if (columnSettings.getIn(['shows', 'reply']) === false) {
+    showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+  }
+
+  if (columnSettings.getIn(['regex', 'body'], '').trim().length > 0) {
+    try {
+      if (showStatus) {
+        const regex = new RegExp(columnSettings.getIn(['regex', 'body']).trim(), 'i');
+        showStatus = !regex.test(statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'unescaped_content']) : statusForId.get('unescaped_content'));
+      }
+    } catch(e) {
+      // Bad regex, don't affect filters
+    }
+  }
+
+  return showStatus;
+}));
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, props) => ({
+    scrollKey: props.scrollKey,
+    shouldUpdateScroll: props.shouldUpdateScroll,
+    statusIds: getStatusIds(state, props),
+    isLoading: state.getIn(['timelines', props.type, 'isLoading'], true),
+    isUnread: state.getIn(['timelines', props.type, 'unread']) > 0,
+    hasMore: !!state.getIn(['timelines', props.type, 'next'])
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { type, id }) => ({
+
+  @debounce(300, true)
+  onScrollToBottom () {
+    dispatch(scrollTopTimeline(type, false));
+    dispatch(expandTimeline(type, id));
+  },
+
+  @debounce(100)
+  onScrollToTop () {
+    dispatch(scrollTopTimeline(type, true));
+  },
+
+  @debounce(100)
+  onScroll () {
+    dispatch(scrollTopTimeline(type, false));
+  }
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
new file mode 100644
index 000000000..d096cb882
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -0,0 +1,169 @@
+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 'react-decoration';
+import { uploadCompose } from '../../actions/compose';
+import { refreshTimeline } from '../../actions/timelines';
+import { refreshNotifications } from '../../actions/notifications';
+import UploadArea from './components/upload_area';
+
+const noOp = () => false;
+
+class UI extends React.PureComponent {
+
+  constructor (props, context) {
+    super(props, context);
+    this.state = {
+      width: window.innerWidth,
+      draggingOver: false
+    };
+    this.handleResize = this.handleResize.bind(this);
+    this.handleDragEnter = this.handleDragEnter.bind(this);
+    this.handleDragOver = this.handleDragOver.bind(this);
+    this.handleDrop = this.handleDrop.bind(this);
+    this.handleDragLeave = this.handleDragLeave.bind(this);
+    this.handleDragEnd = this.handleDragLeave.bind(this)
+    this.closeUploadModal = this.closeUploadModal.bind(this)
+    this.setRef = this.setRef.bind(this);
+  }
+
+  @debounce(500)
+  handleResize () {
+    this.setState({ width: window.innerWidth });
+  }
+
+  handleDragEnter (e) {
+    e.preventDefault();
+
+    if (!this.dragTargets) {
+      this.dragTargets = [];
+    }
+
+    if (this.dragTargets.indexOf(e.target) === -1) {
+      this.dragTargets.push(e.target);
+    }
+
+    if (e.dataTransfer && e.dataTransfer.types.includes('Files')) {
+      this.setState({ draggingOver: true });
+    }
+  }
+
+  handleDragOver (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  }
+
+  handleDrop (e) {
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+
+    if (e.dataTransfer && e.dataTransfer.files.length === 1) {
+      this.props.dispatch(uploadCompose(e.dataTransfer.files));
+    }
+  }
+
+  handleDragLeave (e) {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.dragTargets = this.dragTargets.filter(el => el !== e.target && this.node.contains(el));
+
+    if (this.dragTargets.length > 0) {
+      return;
+    }
+
+    this.setState({ draggingOver: false });
+  }
+
+  closeUploadModal() {
+    this.setState({ draggingOver: false });
+  }
+
+  componentWillMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+    document.addEventListener('dragenter', this.handleDragEnter, false);
+    document.addEventListener('dragover', this.handleDragOver, false);
+    document.addEventListener('drop', this.handleDrop, false);
+    document.addEventListener('dragleave', this.handleDragLeave, false);
+    document.addEventListener('dragend', this.handleDragEnd, false);
+
+    this.props.dispatch(refreshTimeline('home'));
+    this.props.dispatch(refreshNotifications());
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+    document.removeEventListener('dragenter', this.handleDragEnter);
+    document.removeEventListener('dragover', this.handleDragOver);
+    document.removeEventListener('drop', this.handleDrop);
+    document.removeEventListener('dragleave', this.handleDragLeave);
+    document.removeEventListener('dragend', this.handleDragEnd);
+  }
+
+  setRef (c) {
+    this.node = c;
+  }
+
+  render () {
+    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 style={{display: 'flex', flex: '1 1 auto', position: 'relative'}}>{children}</div>
+        </ColumnsArea>
+      );
+    }
+
+    return (
+      <div className='ui' ref={this.setRef}>
+        <TabsBar />
+
+        {mountedColumns}
+
+        <NotificationsContainer />
+        <LoadingBarContainer className="loading-bar" />
+        <ModalContainer />
+        <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+      </div>
+    );
+  }
+
+}
+
+UI.propTypes = {
+  dispatch: PropTypes.func.isRequired,
+  children: PropTypes.node
+};
+
+export default connect()(UI);