about summary refs log tree commit diff
path: root/app/javascript/themes/glitch/features
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/themes/glitch/features')
-rw-r--r--app/javascript/themes/glitch/features/account/components/action_bar.js145
-rw-r--r--app/javascript/themes/glitch/features/account/components/header.js99
-rw-r--r--app/javascript/themes/glitch/features/account_gallery/components/media_item.js39
-rw-r--r--app/javascript/themes/glitch/features/account_gallery/index.js111
-rw-r--r--app/javascript/themes/glitch/features/account_timeline/components/header.js95
-rw-r--r--app/javascript/themes/glitch/features/account_timeline/containers/header_container.js104
-rw-r--r--app/javascript/themes/glitch/features/account_timeline/index.js77
-rw-r--r--app/javascript/themes/glitch/features/blocks/index.js70
-rw-r--r--app/javascript/themes/glitch/features/community_timeline/components/column_settings.js35
-rw-r--r--app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/themes/glitch/features/community_timeline/index.js107
-rw-r--r--app/javascript/themes/glitch/features/compose/components/advanced_options.js62
-rw-r--r--app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js35
-rw-r--r--app/javascript/themes/glitch/features/compose/components/attach_options.js131
-rw-r--r--app/javascript/themes/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/themes/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/themes/glitch/features/compose/components/compose_form.js286
-rw-r--r--app/javascript/themes/glitch/features/compose/components/dropdown.js77
-rw-r--r--app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js376
-rw-r--r--app/javascript/themes/glitch/features/compose/components/navigation_bar.js38
-rw-r--r--app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js200
-rw-r--r--app/javascript/themes/glitch/features/compose/components/reply_indicator.js63
-rw-r--r--app/javascript/themes/glitch/features/compose/components/search.js129
-rw-r--r--app/javascript/themes/glitch/features/compose/components/search_results.js65
-rw-r--r--app/javascript/themes/glitch/features/compose/components/text_icon_button.js29
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload.js96
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_button.js77
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_form.js29
-rw-r--r--app/javascript/themes/glitch/features/compose/components/upload_progress.js42
-rw-r--r--app/javascript/themes/glitch/features/compose/components/warning.js26
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js20
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/compose_form_container.js71
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js82
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/navigation_container.js11
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js24
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js24
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/search_results_container.js8
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js71
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js25
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/upload_button_container.js18
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/upload_container.js21
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/upload_form_container.js8
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/themes/glitch/features/compose/containers/warning_container.js24
-rw-r--r--app/javascript/themes/glitch/features/compose/index.js126
-rw-r--r--app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/themes/glitch/features/direct_timeline/index.js107
-rw-r--r--app/javascript/themes/glitch/features/favourited_statuses/index.js94
-rw-r--r--app/javascript/themes/glitch/features/favourites/index.js60
-rw-r--r--app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js49
-rw-r--r--app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js26
-rw-r--r--app/javascript/themes/glitch/features/follow_requests/index.js71
-rw-r--r--app/javascript/themes/glitch/features/followers/index.js93
-rw-r--r--app/javascript/themes/glitch/features/following/index.js93
-rw-r--r--app/javascript/themes/glitch/features/generic_not_found/index.js11
-rw-r--r--app/javascript/themes/glitch/features/getting_started/index.js145
-rw-r--r--app/javascript/themes/glitch/features/hashtag_timeline/index.js118
-rw-r--r--app/javascript/themes/glitch/features/home_timeline/components/column_settings.js46
-rw-r--r--app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js21
-rw-r--r--app/javascript/themes/glitch/features/home_timeline/index.js90
-rw-r--r--app/javascript/themes/glitch/features/local_settings/index.js68
-rw-r--r--app/javascript/themes/glitch/features/local_settings/navigation/index.js74
-rw-r--r--app/javascript/themes/glitch/features/local_settings/navigation/item/index.js69
-rw-r--r--app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss27
-rw-r--r--app/javascript/themes/glitch/features/local_settings/navigation/style.scss10
-rw-r--r--app/javascript/themes/glitch/features/local_settings/page/index.js212
-rw-r--r--app/javascript/themes/glitch/features/local_settings/page/item/index.js90
-rw-r--r--app/javascript/themes/glitch/features/local_settings/page/item/style.scss7
-rw-r--r--app/javascript/themes/glitch/features/local_settings/page/style.scss9
-rw-r--r--app/javascript/themes/glitch/features/local_settings/style.scss34
-rw-r--r--app/javascript/themes/glitch/features/mutes/index.js70
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/clear_column_button.js17
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/column_settings.js86
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/follow.js97
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/notification.js88
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/overlay.js57
-rw-r--r--app/javascript/themes/glitch/features/notifications/components/setting_toggle.js34
-rw-r--r--app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js44
-rw-r--r--app/javascript/themes/glitch/features/notifications/containers/notification_container.js27
-rw-r--r--app/javascript/themes/glitch/features/notifications/containers/overlay_container.js18
-rw-r--r--app/javascript/themes/glitch/features/notifications/index.js193
-rw-r--r--app/javascript/themes/glitch/features/pinned_statuses/index.js59
-rw-r--r--app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/themes/glitch/features/public_timeline/index.js107
-rw-r--r--app/javascript/themes/glitch/features/reblogs/index.js60
-rw-r--r--app/javascript/themes/glitch/features/report/components/status_check_box.js37
-rw-r--r--app/javascript/themes/glitch/features/report/containers/status_check_box_container.js19
-rw-r--r--app/javascript/themes/glitch/features/standalone/compose/index.js20
-rw-r--r--app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js70
-rw-r--r--app/javascript/themes/glitch/features/standalone/public_timeline/index.js76
-rw-r--r--app/javascript/themes/glitch/features/status/components/action_bar.js129
-rw-r--r--app/javascript/themes/glitch/features/status/components/card.js125
-rw-r--r--app/javascript/themes/glitch/features/status/components/detailed_status.js128
-rw-r--r--app/javascript/themes/glitch/features/status/containers/card_container.js8
-rw-r--r--app/javascript/themes/glitch/features/status/index.js343
-rw-r--r--app/javascript/themes/glitch/features/ui/components/actions_modal.js74
-rw-r--r--app/javascript/themes/glitch/features/ui/components/boost_modal.js84
-rw-r--r--app/javascript/themes/glitch/features/ui/components/bundle.js102
-rw-r--r--app/javascript/themes/glitch/features/ui/components/bundle_column_error.js44
-rw-r--r--app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js53
-rw-r--r--app/javascript/themes/glitch/features/ui/components/column.js74
-rw-r--r--app/javascript/themes/glitch/features/ui/components/column_header.js35
-rw-r--r--app/javascript/themes/glitch/features/ui/components/column_link.js39
-rw-r--r--app/javascript/themes/glitch/features/ui/components/column_loading.js30
-rw-r--r--app/javascript/themes/glitch/features/ui/components/column_subheading.js16
-rw-r--r--app/javascript/themes/glitch/features/ui/components/columns_area.js174
-rw-r--r--app/javascript/themes/glitch/features/ui/components/confirmation_modal.js53
-rw-r--r--app/javascript/themes/glitch/features/ui/components/doodle_modal.js614
-rw-r--r--app/javascript/themes/glitch/features/ui/components/drawer_loading.js11
-rw-r--r--app/javascript/themes/glitch/features/ui/components/embed_modal.js84
-rw-r--r--app/javascript/themes/glitch/features/ui/components/image_loader.js152
-rw-r--r--app/javascript/themes/glitch/features/ui/components/media_modal.js126
-rw-r--r--app/javascript/themes/glitch/features/ui/components/modal_loading.js20
-rw-r--r--app/javascript/themes/glitch/features/ui/components/modal_root.js131
-rw-r--r--app/javascript/themes/glitch/features/ui/components/mute_modal.js105
-rw-r--r--app/javascript/themes/glitch/features/ui/components/onboarding_modal.js323
-rw-r--r--app/javascript/themes/glitch/features/ui/components/report_modal.js105
-rw-r--r--app/javascript/themes/glitch/features/ui/components/tabs_bar.js84
-rw-r--r--app/javascript/themes/glitch/features/ui/components/upload_area.js52
-rw-r--r--app/javascript/themes/glitch/features/ui/components/video_modal.js33
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/bundle_container.js19
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/columns_area_container.js8
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js8
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/modal_container.js16
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/notifications_container.js18
-rw-r--r--app/javascript/themes/glitch/features/ui/containers/status_list_container.js73
-rw-r--r--app/javascript/themes/glitch/features/ui/index.js442
-rw-r--r--app/javascript/themes/glitch/features/video/index.js288
130 files changed, 10361 insertions, 0 deletions
diff --git a/app/javascript/themes/glitch/features/account/components/action_bar.js b/app/javascript/themes/glitch/features/account/components/action_bar.js
new file mode 100644
index 000000000..0edd5c848
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account/components/action_bar.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { Link } from 'react-router-dom';
+import { defineMessages, injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+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}' },
+  share: { id: 'account.share', defaultMessage: 'Share @{name}\'s profile' },
+  media: { id: 'account.media', defaultMessage: 'Media' },
+  blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
+  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onFollow: PropTypes.func,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleShare = () => {
+    navigator.share({
+      url: this.props.account.get('url'),
+    });
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let menu = [];
+    let extraInfo = '';
+
+    menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+    if ('share' in navigator) {
+      menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+    }
+    menu.push(null);
+    menu.push({ text: intl.formatMessage(messages.media), to: `/accounts/${account.get('id')}/media` });
+    menu.push(null);
+
+    if (account.get('id') === me) {
+      menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
+    } else {
+      const following = account.getIn(['relationship', 'following']);
+      if (following) {
+        if (following.get('reblogs')) {
+          menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+        } else {
+          menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle });
+        }
+      }
+
+      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')) {
+      const domain = account.get('acct').split('@')[1];
+
+      extraInfo = (
+        <div className='account__disclaimer'>
+          <FormattedMessage
+            id='account.disclaimer_full'
+            defaultMessage="Information below may reflect the user's profile incompletely."
+          />
+          {' '}
+          <a target='_blank' rel='noopener' href={account.get('url')}>
+            <FormattedMessage id='account.view_full_profile' defaultMessage='View full profile' />
+          </a>
+        </div>
+      );
+
+      menu.push(null);
+
+      if (account.getIn(['relationship', 'domain_blocking'])) {
+        menu.push({ text: intl.formatMessage(messages.unblockDomain, { domain }), action: this.props.onUnblockDomain });
+      } else {
+        menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.props.onBlockDomain });
+      }
+    }
+
+    return (
+      <div>
+        {extraInfo}
+
+        <div className='account__action-bar'>
+          <div className='account__action-bar-dropdown'>
+            <DropdownMenuContainer 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')} /></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')} /></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')} /></strong>
+            </Link>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/account/components/header.js b/app/javascript/themes/glitch/features/account/components/header.js
new file mode 100644
index 000000000..696bb1991
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account/components/header.js
@@ -0,0 +1,99 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+
+import emojify from 'themes/glitch/util/emoji';
+import { me } from 'themes/glitch/util/initial_state';
+import { processBio } from 'themes/glitch/util/bio_metadata';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+});
+
+@injectIntl
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    onFollow: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { account, intl } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    let displayName = account.get('display_name_html');
+    let info        = '';
+    let actionBtn   = '';
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info = <span className='account--follows-info'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>;
+    }
+
+    if (me !== account.get('id')) {
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <IconButton size={26} active icon='hourglass' title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />
+          </div>
+        );
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = (
+          <div className='account--action-button'>
+            <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>
+        );
+      }
+    }
+
+    const { text, metadata } = processBio(account.get('note'));
+
+    return (
+      <div className='account__header__wrapper'>
+        <div className='account__header' style={{ backgroundImage: `url(${account.get('header')})` }}>
+          <div>
+            <Avatar account={account} size={90} />
+
+            <span className='account__header__display-name' dangerouslySetInnerHTML={{ __html: displayName }} />
+            <span className='account__header__username'>@{account.get('acct')} {account.get('locked') ? <i className='fa fa-lock' /> : null}</span>
+            <div className='account__header__content' dangerouslySetInnerHTML={{ __html: emojify(text) }} />
+
+            {info}
+            {actionBtn}
+          </div>
+        </div>
+
+        {metadata.length && (
+          <table className='account__metadata'>
+            <tbody>
+              {(() => {
+                let data = [];
+                for (let i = 0; i < metadata.length; i++) {
+                  data.push(
+                    <tr key={i}>
+                      <th scope='row'><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][0]) }} /></th>
+                      <td><div dangerouslySetInnerHTML={{ __html: emojify(metadata[i][1]) }} /></td>
+                    </tr>
+                  );
+                }
+                return data;
+              })()}
+            </tbody>
+          </table>
+        ) || null}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_gallery/components/media_item.js b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js
new file mode 100644
index 000000000..88c9156b5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_gallery/components/media_item.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Permalink from 'themes/glitch/components/permalink';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { media } = this.props;
+    const status = media.get('status');
+
+    let content, style;
+
+    if (media.get('type') === 'gifv') {
+      content = <span className='media-gallery__gifv__label'>GIF</span>;
+    }
+
+    if (!status.get('sensitive')) {
+      style = { backgroundImage: `url(${media.get('preview_url')})` };
+    }
+
+    return (
+      <div className='account-gallery__item'>
+        <Permalink
+          to={`/statuses/${status.get('id')}`}
+          href={status.get('url')}
+          style={style}
+        >
+          {content}
+        </Permalink>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_gallery/index.js b/app/javascript/themes/glitch/features/account_gallery/index.js
new file mode 100644
index 000000000..a21c089da
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_gallery/index.js
@@ -0,0 +1,111 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'themes/glitch/actions/accounts';
+import { refreshAccountMediaTimeline, expandAccountMediaTimeline } from 'themes/glitch/actions/timelines';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from 'themes/glitch/selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import { FormattedMessage } from 'react-intl';
+import { ScrollContainer } from 'react-router-scroll-4';
+import LoadMore from 'themes/glitch/components/load_more';
+
+const mapStateToProps = (state, props) => ({
+  medias: getAccountGallery(state, props.params.accountId),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}:media`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountGallery extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    medias: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountMediaTimeline(this.props.params.accountId));
+    }
+  }
+
+  handleScrollToBottom = () => {
+    if (this.props.hasMore) {
+      this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+
+    if (150 > offset && !this.props.isLoading) {
+      this.handleScrollToBottom();
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.handleScrollToBottom();
+  }
+
+  render () {
+    const { medias, isLoading, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!medias && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (!isLoading && medias.size > 0 && hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='account_gallery'>
+          <div className='scrollable' onScroll={this.handleScroll}>
+            <HeaderContainer accountId={this.props.params.accountId} />
+
+            <div className='account-section-headline'>
+              <FormattedMessage id='account.media' defaultMessage='Media' />
+            </div>
+
+            <div className='account-gallery__container'>
+              {medias.map(media =>
+                <MediaItem
+                  key={media.get('id')}
+                  media={media}
+                />
+              )}
+              {loadMore}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_timeline/components/header.js b/app/javascript/themes/glitch/features/account_timeline/components/header.js
new file mode 100644
index 000000000..c719a7bcb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/components/header.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from 'themes/glitch/features/account/components/header';
+import ActionBar from 'themes/glitch/features/account/components/action_bar';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMention = () => {
+    this.props.onMention(this.props.account, this.context.router.history);
+  }
+
+  handleReport = () => {
+    this.props.onReport(this.props.account);
+  }
+
+  handleReblogToggle = () => {
+    this.props.onReblogToggle(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  handleBlockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onBlockDomain(domain, this.props.account.get('id'));
+  }
+
+  handleUnblockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onUnblockDomain(domain, this.props.account.get('id'));
+  }
+
+  render () {
+    const { account } = this.props;
+
+    if (account === null) {
+      return <MissingIndicator />;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        <InnerHeader
+          account={account}
+          onFollow={this.handleFollow}
+        />
+
+        <ActionBar
+          account={account}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onReblogToggle={this.handleReblogToggle}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+          onBlockDomain={this.handleBlockDomain}
+          onUnblockDomain={this.handleUnblockDomain}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..766b57b56
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/containers/header_container.js
@@ -0,0 +1,104 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  blockAccount,
+  unblockAccount,
+  unmuteAccount,
+} from 'themes/glitch/actions/accounts';
+import { mentionCompose } from 'themes/glitch/actions/compose';
+import { initMuteModal } from 'themes/glitch/actions/mutes';
+import { initReport } from 'themes/glitch/actions/reports';
+import { openModal } from 'themes/glitch/actions/modal';
+import { blockDomain, unblockDomain } from 'themes/glitch/actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  blockConfirm: { id: 'confirmations.block.confirm', defaultMessage: 'Block' },
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onFollow (account) {
+    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
+      if (unfollowModal) {
+        dispatch(openModal('CONFIRM', {
+          message: <FormattedMessage id='confirmations.unfollow.message' defaultMessage='Are you sure you want to unfollow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
+          confirm: intl.formatMessage(messages.unfollowConfirm),
+          onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
+        }));
+      } else {
+        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));
+  },
+
+  onReblogToggle (account) {
+    if (account.getIn(['relationship', 'following', 'reblogs'])) {
+      dispatch(followAccount(account.get('id'), false));
+    } else {
+      dispatch(followAccount(account.get('id'), true));
+    }
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+  onBlockDomain (domain, accountId) {
+    dispatch(openModal('CONFIRM', {
+      message: <FormattedMessage id='confirmations.domain_block.message' defaultMessage='Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.' values={{ domain: <strong>{domain}</strong> }} />,
+      confirm: intl.formatMessage(messages.blockDomainConfirm),
+      onConfirm: () => dispatch(blockDomain(domain, accountId)),
+    }));
+  },
+
+  onUnblockDomain (domain, accountId) {
+    dispatch(unblockDomain(domain, accountId));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/themes/glitch/features/account_timeline/index.js b/app/javascript/themes/glitch/features/account_timeline/index.js
new file mode 100644
index 000000000..81336ef3a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/account_timeline/index.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'themes/glitch/actions/accounts';
+import { refreshAccountTimeline, expandAccountTimeline } from 'themes/glitch/actions/timelines';
+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 { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  statusIds: state.getIn(['timelines', `account:${props.params.accountId}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}`, 'isLoading']),
+  hasMore: !!state.getIn(['timelines', `account:${props.params.accountId}`, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class AccountTimeline extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(refreshAccountTimeline(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(refreshAccountTimeline(nextProps.params.accountId));
+    }
+  }
+
+  handleScrollToBottom = () => {
+    if (!this.props.isLoading && this.props.hasMore) {
+      this.props.dispatch(expandAccountTimeline(this.props.params.accountId));
+    }
+  }
+
+  render () {
+    const { statusIds, isLoading, hasMore } = this.props;
+
+    if (!statusIds && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='account'>
+        <ColumnBackButton />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          scrollKey='account_timeline'
+          statusIds={statusIds}
+          isLoading={isLoading}
+          hasMore={hasMore}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/blocks/index.js b/app/javascript/themes/glitch/features/blocks/index.js
new file mode 100644
index 000000000..70630818c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/blocks/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import { fetchBlocks, expandBlocks } from 'themes/glitch/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']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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 name='blocks' 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js
new file mode 100644
index 000000000..988e36308
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/components/column_settings.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingText from 'themes/glitch/components/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' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..cd9c34365
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'community']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['community', ...key], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/community_timeline/index.js b/app/javascript/themes/glitch/features/community_timeline/index.js
new file mode 100644
index 000000000..9d255bd01
--- /dev/null
+++ b/app/javascript/themes/glitch/features/community_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+  refreshCommunityTimeline,
+  expandCommunityTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'community', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class CommunityTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('COMMUNITY', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshCommunityTimeline());
+    this.disconnect = dispatch(connectCommunityStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandCommunityTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='local'>
+        <ColumnHeader
+          icon='users'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`community_timeline-${columnId}`}
+          timelineId='community'
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options.js b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
new file mode 100644
index 000000000..045bad2e5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options.js
@@ -0,0 +1,62 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports.
+import ComposeAdvancedOptionsToggle from './advanced_options_toggle';
+import ComposeDropdown from './dropdown';
+
+const messages = defineMessages({
+  local_only_short            :
+    { id: 'advanced-options.local-only.short', defaultMessage: 'Local-only' },
+  local_only_long             :
+    { id: 'advanced-options.local-only.long', defaultMessage: 'Do not post to other instances' },
+  advanced_options_icon_title :
+    { id: 'advanced_options.icon_title', defaultMessage: 'Advanced options' },
+});
+
+@injectIntl
+export default class ComposeAdvancedOptions extends React.PureComponent {
+
+  static propTypes = {
+    values   : ImmutablePropTypes.contains({
+      do_not_federate : PropTypes.bool.isRequired,
+    }).isRequired,
+    onChange : PropTypes.func.isRequired,
+    intl     : PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, values } = this.props;
+    const options = [
+      { icon: 'wifi', shortText: messages.local_only_short, longText: messages.local_only_long, name: 'do_not_federate' },
+    ];
+    const anyEnabled = values.some((enabled) => enabled);
+
+    const optionElems = options.map((option) => {
+      return (
+        <ComposeAdvancedOptionsToggle
+          onChange={this.props.onChange}
+          active={values.get(option.name)}
+          key={option.name}
+          name={option.name}
+          shortText={intl.formatMessage(option.shortText)}
+          longText={intl.formatMessage(option.longText)}
+        />
+      );
+    });
+
+    return (
+      <ComposeDropdown
+        title={intl.formatMessage(messages.advanced_options_icon_title)}
+        icon='home'
+        highlight={anyEnabled}
+      >
+        {optionElems}
+      </ComposeDropdown>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
new file mode 100644
index 000000000..98b3b6a44
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/advanced_options_toggle.js
@@ -0,0 +1,35 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import Toggle from 'react-toggle';
+
+export default class ComposeAdvancedOptionsToggle extends React.PureComponent {
+
+  static propTypes = {
+    onChange: PropTypes.func.isRequired,
+    active: PropTypes.bool.isRequired,
+    name: PropTypes.string.isRequired,
+    shortText: PropTypes.string.isRequired,
+    longText: PropTypes.string.isRequired,
+  }
+
+  onToggle = () => {
+    this.props.onChange(this.props.name);
+  }
+
+  render() {
+    const { active, shortText, longText } = this.props;
+    return (
+      <div role='button' tabIndex='0' className='advanced-options-dropdown__option' onClick={this.onToggle}>
+        <div className='advanced-options-dropdown__option__toggle'>
+          <Toggle checked={active} onChange={this.onToggle} />
+        </div>
+        <div className='advanced-options-dropdown__option__content'>
+          <strong>{shortText}</strong>
+          {longText}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/attach_options.js b/app/javascript/themes/glitch/features/compose/components/attach_options.js
new file mode 100644
index 000000000..c396714f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/attach_options.js
@@ -0,0 +1,131 @@
+//  Package imports  //
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports  //
+import ComposeDropdown from './dropdown';
+import { uploadCompose } from 'themes/glitch/actions/compose';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+  upload :
+    { id: 'compose.attach.upload', defaultMessage: 'Upload a file' },
+  doodle :
+    { id: 'compose.attach.doodle', defaultMessage: 'Draw something' },
+  attach :
+    { id: 'compose.attach', defaultMessage: 'Attach...' },
+});
+
+const mapStateToProps = state => ({
+  // This horrible expression is copied from vanilla upload_button_container
+  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']),
+  acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSelectFile (files) {
+    dispatch(uploadCompose(files));
+  },
+  onOpenDoodle () {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+});
+
+@injectIntl
+@connect(mapStateToProps, mapDispatchToProps)
+export default class ComposeAttachOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl     : PropTypes.object.isRequired,
+    resetFileKey: PropTypes.number,
+    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+    disabled: PropTypes.bool,
+    onSelectFile: PropTypes.func.isRequired,
+    onOpenDoodle: PropTypes.func.isRequired,
+  };
+
+  handleItemClick = bt => {
+    if (bt === 'upload') {
+      this.fileElement.click();
+    }
+
+    if (bt === 'doodle') {
+      this.props.onOpenDoodle();
+    }
+
+    this.dropdown.setState({ open: false });
+  };
+
+  handleFileChange = (e) => {
+    if (e.target.files.length > 0) {
+      this.props.onSelectFile(e.target.files);
+    }
+  }
+
+  setFileRef = (c) => {
+    this.fileElement = c;
+  }
+
+  setDropdownRef = (c) => {
+    this.dropdown = c;
+  }
+
+  render () {
+    const { intl, resetFileKey, disabled, acceptContentTypes } = this.props;
+
+    const options = [
+      { icon: 'cloud-upload', text: messages.upload, name: 'upload' },
+      { icon: 'paint-brush', text: messages.doodle, name: 'doodle' },
+    ];
+
+    const optionElems = options.map((item) => {
+      const hdl = () => this.handleItemClick(item.name);
+      return (
+        <div
+          role='button'
+          tabIndex='0'
+          key={item.name}
+          onClick={hdl}
+          className='privacy-dropdown__option'
+        >
+          <div className='privacy-dropdown__option__icon'>
+            <i className={`fa fa-fw fa-${item.icon}`} />
+          </div>
+
+          <div className='privacy-dropdown__option__content'>
+            <strong>{intl.formatMessage(item.text)}</strong>
+          </div>
+        </div>
+      );
+    });
+
+    return (
+      <div>
+        <ComposeDropdown
+          title={intl.formatMessage(messages.attach)}
+          icon='paperclip'
+          disabled={disabled}
+          ref={this.setDropdownRef}
+        >
+          {optionElems}
+        </ComposeDropdown>
+        <input
+          key={resetFileKey}
+          ref={this.setFileRef}
+          type='file'
+          multiple={false}
+          accept={acceptContentTypes.toArray().join(',')}
+          onChange={this.handleFileChange}
+          disabled={disabled}
+          style={{ display: 'none' }}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..4a98d89fe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class AutosuggestAccount extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='autosuggest-account'>
+        <div className='autosuggest-account-icon'><Avatar account={account} size={18} /></div>
+        <DisplayName account={account} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/character_counter.js b/app/javascript/themes/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/character_counter.js
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { length } from 'stringz';
+
+export default class CharacterCounter extends React.PureComponent {
+
+  static propTypes = {
+    text: PropTypes.string.isRequired,
+    max: PropTypes.number.isRequired,
+  };
+
+  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);
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/compose_form.js b/app/javascript/themes/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..54b1944a4
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,286 @@
+import React from 'react';
+import CharacterCounter from './character_counter';
+import Button from 'themes/glitch/components/button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from 'themes/glitch/components/autosuggest_textarea';
+import { defineMessages, injectIntl } from 'react-intl';
+import Collapsable from 'themes/glitch/components/collapsable';
+import SpoilerButtonContainer from '../containers/spoiler_button_container';
+import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
+import ComposeAdvancedOptionsContainer from '../containers/advanced_options_container';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { length } from 'stringz';
+import { countableText } from 'themes/glitch/util/counter';
+import ComposeAttachOptions from './attach_options';
+import initialState from 'themes/glitch/util/initial_state';
+
+const maxChars = initialState.max_toot_chars;
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
+  publishLoud: { id: 'compose_form.publish_loud', defaultMessage: '{publish}!' },
+});
+
+@injectIntl
+export default class ComposeForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string.isRequired,
+    suggestion_token: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    advanced_options: ImmutablePropTypes.contains({
+      do_not_federate: PropTypes.bool,
+    }),
+    spoiler_text: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    preselectDate: PropTypes.instanceOf(Date),
+    is_submitting: PropTypes.bool,
+    is_uploading: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onPrivacyChange: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    onChangeSpoilerText: PropTypes.func.isRequired,
+    onPaste: PropTypes.func.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    showSearch: PropTypes.bool,
+    settings : ImmutablePropTypes.map.isRequired,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit2 = () => {
+    this.props.onPrivacyChange(this.props.settings.get('side_arm'));
+    this.handleSubmit();
+  }
+
+  handleSubmit = () => {
+    if (this.props.text !== this.autosuggestTextarea.textarea.value) {
+      // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
+      // Update the state to match the current text
+      this.props.onChange(this.autosuggestTextarea.textarea.value);
+    }
+
+    this.props.onSubmit();
+  }
+
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  }
+
+  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();
+    } else if(prevProps.is_submitting && !this.props.is_submitting) {
+      this.autosuggestTextarea.textarea.focus();
+    }
+  }
+
+  setAutosuggestTextarea = (c) => {
+    this.autosuggestTextarea = c;
+  }
+
+  handleEmojiPick = (data) => {
+    const position     = this.autosuggestTextarea.textarea.selectionStart;
+    const emojiChar    = data.native;
+    this._restoreCaret = position + emojiChar.length + 1;
+    this.props.onPickEmoji(position, data);
+  }
+
+  render () {
+    const { intl, onPaste, showSearch } = this.props;
+    const disabled = this.props.is_submitting;
+    const maybeEye = (this.props.advanced_options && this.props.advanced_options.do_not_federate) ? ' 👁️' : '';
+    const text     = [this.props.spoiler_text, countableText(this.props.text), maybeEye].join('');
+
+    const secondaryVisibility = this.props.settings.get('side_arm');
+    let showSideArm = secondaryVisibility !== 'none';
+
+    let publishText = '';
+    let publishText2 = '';
+    let title = '';
+    let title2 = '';
+
+    const privacyIcons = {
+      none: '',
+      public: 'globe',
+      unlisted: 'unlock-alt',
+      private: 'lock',
+      direct: 'envelope',
+    };
+
+    title = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${this.props.privacy}.short` })}`;
+
+    if (showSideArm) {
+      // Enhanced behavior with dual toot buttons
+      publishText = (
+        <span>
+          {
+            <i
+              className={`fa fa-${privacyIcons[this.props.privacy]}`}
+              style={{ paddingRight: '5px' }}
+            />
+          }{intl.formatMessage(messages.publish)}
+        </span>
+      );
+
+      title2 = `${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${secondaryVisibility}.short` })}`;
+      publishText2 = (
+        <i
+          className={`fa fa-${privacyIcons[secondaryVisibility]}`}
+          aria-label={title2}
+        />
+      );
+    } else {
+      // Original vanilla behavior - no icon if public or unlisted
+      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 = this.props.privacy !== 'unlisted' ? intl.formatMessage(messages.publishLoud, { publish: intl.formatMessage(messages.publish) }) : intl.formatMessage(messages.publish);
+      }
+    }
+
+    const submitDisabled = disabled || this.props.is_uploading || length(text) > maxChars || (text.length !== 0 && text.trim().length === 0);
+
+    return (
+      <div className='compose-form'>
+        <Collapsable isVisible={this.props.spoiler} fullHeight={50}>
+          <div className='spoiler-input'>
+            <label>
+              <span style={{ display: 'none' }}>{intl.formatMessage(messages.spoiler_placeholder)}</span>
+              <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' />
+            </label>
+          </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}
+            autoFocus={!showSearch && !isMobile(window.innerWidth)}
+          />
+
+          <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
+        </div>
+
+        <div className='compose-form__modifiers'>
+          <UploadFormContainer />
+        </div>
+
+        <div className='compose-form__buttons'>
+          <ComposeAttachOptions />
+          <SensitiveButtonContainer />
+          <div className='compose-form__buttons-separator' />
+          <PrivacyDropdownContainer />
+          <SpoilerButtonContainer />
+          <ComposeAdvancedOptionsContainer />
+        </div>
+
+        <div className='compose-form__publish'>
+          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
+          <div className='compose-form__publish-button-wrapper'>
+            {
+              showSideArm ?
+                <Button
+                  className='compose-form__publish__side-arm'
+                  text={publishText2}
+                  title={title2}
+                  onClick={this.handleSubmit2}
+                  disabled={submitDisabled}
+                /> : ''
+            }
+            <Button
+              className='compose-form__publish__primary'
+              text={publishText}
+              title={title}
+              onClick={this.handleSubmit}
+              disabled={submitDisabled}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/dropdown.js b/app/javascript/themes/glitch/features/compose/components/dropdown.js
new file mode 100644
index 000000000..f3d9f094e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/dropdown.js
@@ -0,0 +1,77 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+
+//  Our imports.
+import IconButton from 'themes/glitch/components/icon_button';
+
+const iconStyle = {
+  height     : null,
+  lineHeight : '27px',
+};
+
+export default class ComposeDropdown extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string.isRequired,
+    icon: PropTypes.string,
+    highlight: PropTypes.bool,
+    disabled: PropTypes.bool,
+    children: PropTypes.arrayOf(PropTypes.node).isRequired,
+  };
+
+  state = {
+    open: false,
+  };
+
+  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);
+  }
+
+  onToggleDropdown = () => {
+    if (this.props.disabled) return;
+    this.setState({ open: !this.state.open });
+  };
+
+  setRef = (c) => {
+    this.node = c;
+  };
+
+  render () {
+    const { open } = this.state;
+    let { highlight, title, icon, disabled } = this.props;
+
+    if (!icon) icon = 'ellipsis-h';
+
+    return (
+      <div ref={this.setRef} className={`advanced-options-dropdown ${open ?  'open' : ''} ${highlight ? 'active' : ''} `}>
+        <div className='advanced-options-dropdown__value'>
+          <IconButton
+            className={'inverted'}
+            title={title}
+            icon={icon} active={open || highlight}
+            size={18}
+            style={iconStyle}
+            disabled={disabled}
+            onClick={this.onToggleDropdown}
+          />
+        </div>
+        <div className='advanced-options-dropdown__dropdown'>
+          {this.props.children}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
new file mode 100644
index 000000000..fd59efb85
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/emoji_picker_dropdown.js
@@ -0,0 +1,376 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from 'themes/glitch/util/async-components';
+import Overlay from 'react-overlays/lib/Overlay';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import detectPassiveEvents from 'detect-passive-events';
+import { buildCustomEmojis } from 'themes/glitch/util/emoji';
+
+const messages = defineMessages({
+  emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
+  emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
+  emoji_not_found: { id: 'emoji_button.not_found', defaultMessage: 'No emojos!! (╯°□°)╯︵ ┻━┻' },
+  custom: { id: 'emoji_button.custom', defaultMessage: 'Custom' },
+  recent: { id: 'emoji_button.recent', defaultMessage: 'Frequently used' },
+  search_results: { id: 'emoji_button.search_results', defaultMessage: 'Search results' },
+  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 assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+const categoriesSort = [
+  'recent',
+  'custom',
+  'people',
+  'nature',
+  'foods',
+  'activity',
+  'places',
+  'objects',
+  'symbols',
+  'flags',
+];
+
+class ModifierPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onSelect: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleClick = e => {
+    this.props.onSelect(e.currentTarget.getAttribute('data-index') * 1);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.active) {
+      this.attachListeners();
+    } else {
+      this.removeListeners();
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeListeners();
+  }
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  attachListeners () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  removeListeners () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { active } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
+        <button onClick={this.handleClick} data-index={1}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
+      </div>
+    );
+  }
+
+}
+
+class ModifierPicker extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    modifier: PropTypes.number,
+    onChange: PropTypes.func,
+    onClose: PropTypes.func,
+    onOpen: PropTypes.func,
+  };
+
+  handleClick = () => {
+    if (this.props.active) {
+      this.props.onClose();
+    } else {
+      this.props.onOpen();
+    }
+  }
+
+  handleSelect = modifier => {
+    this.props.onChange(modifier);
+    this.props.onClose();
+  }
+
+  render () {
+    const { active, modifier } = this.props;
+
+    return (
+      <div className='emoji-picker-dropdown__modifiers'>
+        <Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
+        <ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+class EmojiPickerMenu extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    loading: PropTypes.bool,
+    onClose: PropTypes.func.isRequired,
+    onPick: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    placement: PropTypes.string,
+    arrowOffsetLeft: PropTypes.string,
+    arrowOffsetTop: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    skinTone: PropTypes.number.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+  };
+
+  static defaultProps = {
+    style: {},
+    loading: true,
+    placement: 'bottom',
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  getI18n = () => {
+    const { intl } = this.props;
+
+    return {
+      search: intl.formatMessage(messages.emoji_search),
+      notfound: intl.formatMessage(messages.emoji_not_found),
+      categories: {
+        search: intl.formatMessage(messages.search_results),
+        recent: intl.formatMessage(messages.recent),
+        people: intl.formatMessage(messages.people),
+        nature: intl.formatMessage(messages.nature),
+        foods: intl.formatMessage(messages.food),
+        activity: intl.formatMessage(messages.activity),
+        places: intl.formatMessage(messages.travel),
+        objects: intl.formatMessage(messages.objects),
+        symbols: intl.formatMessage(messages.symbols),
+        flags: intl.formatMessage(messages.flags),
+        custom: intl.formatMessage(messages.custom),
+      },
+    };
+  }
+
+  handleClick = emoji => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+
+    this.props.onClose();
+    this.props.onPick(emoji);
+  }
+
+  handleModifierOpen = () => {
+    this.setState({ modifierOpen: true });
+  }
+
+  handleModifierClose = () => {
+    this.setState({ modifierOpen: false });
+  }
+
+  handleModifierChange = modifier => {
+    this.props.onSkinTone(modifier);
+  }
+
+  render () {
+    const { loading, style, intl, custom_emojis, skinTone, frequentlyUsedEmojis } = this.props;
+
+    if (loading) {
+      return <div style={{ width: 299 }} />;
+    }
+
+    const title = intl.formatMessage(messages.emoji);
+    const { modifierOpen } = this.state;
+
+    return (
+      <div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
+        <EmojiPicker
+          perLine={8}
+          emojiSize={22}
+          sheetSize={32}
+          custom={buildCustomEmojis(custom_emojis)}
+          color=''
+          emoji=''
+          set='twitter'
+          title={title}
+          i18n={this.getI18n()}
+          onClick={this.handleClick}
+          include={categoriesSort}
+          recent={frequentlyUsedEmojis}
+          skin={skinTone}
+          showPreview={false}
+          backgroundImageFn={backgroundImageFn}
+          emojiTooltip
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class EmojiPickerDropdown extends React.PureComponent {
+
+  static propTypes = {
+    custom_emojis: ImmutablePropTypes.list,
+    frequentlyUsedEmojis: PropTypes.arrayOf(PropTypes.string),
+    intl: PropTypes.object.isRequired,
+    onPickEmoji: PropTypes.func.isRequired,
+    onSkinTone: PropTypes.func.isRequired,
+    skinTone: PropTypes.number.isRequired,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  }
+
+  onShowDropdown = () => {
+    this.setState({ active: true });
+
+    if (!EmojiPicker) {
+      this.setState({ loading: true });
+
+      EmojiPickerAsync().then(EmojiMart => {
+        EmojiPicker = EmojiMart.Picker;
+        Emoji       = EmojiMart.Emoji;
+
+        this.setState({ loading: false });
+      }).catch(() => {
+        this.setState({ loading: false });
+      });
+    }
+  }
+
+  onHideDropdown = () => {
+    this.setState({ active: false });
+  }
+
+  onToggle = (e) => {
+    if (!this.state.loading && (!e.key || e.key === 'Enter')) {
+      if (this.state.active) {
+        this.onHideDropdown();
+      } else {
+        this.onShowDropdown();
+      }
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading } = this.state;
+
+    return (
+      <div className='emoji-picker-dropdown' onKeyDown={this.handleKeyDown}>
+        <div ref={this.setTargetRef} className='emoji-button' title={title} aria-label={title} aria-expanded={active} role='button' onClick={this.onToggle} onKeyDown={this.onToggle} tabIndex={0}>
+          <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />
+        </div>
+
+        <Overlay show={active} placement='bottom' target={this.findTarget}>
+          <EmojiPickerMenu
+            custom_emojis={this.props.custom_emojis}
+            loading={loading}
+            onClose={this.onHideDropdown}
+            onPick={onPickEmoji}
+            onSkinTone={onSkinTone}
+            skinTone={skinTone}
+            frequentlyUsedEmojis={frequentlyUsedEmojis}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/navigation_bar.js b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..24a70949b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import Permalink from 'themes/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='navigation-bar'>
+        <Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+          <span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
+          <Avatar account={this.props.account} 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>
+
+        <IconButton title='' icon='close' onClick={this.props.onClose} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
new file mode 100644
index 000000000..0cd92d174
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/privacy_dropdown.js
@@ -0,0 +1,200 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+import IconButton from 'themes/glitch/components/icon_button';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import detectPassiveEvents from 'detect-passive-events';
+import classNames from 'classnames';
+
+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 listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+class PrivacyDropdownMenu extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+    items: PropTypes.array.isRequired,
+    value: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onChange: PropTypes.func.isRequired,
+  };
+
+  handleDocumentClick = e => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  handleClick = e => {
+    if (e.key === 'Escape') {
+      this.props.onClose();
+    } else if (!e.key || e.key === 'Enter') {
+      const value = e.currentTarget.getAttribute('data-index');
+
+      e.preventDefault();
+
+      this.props.onClose();
+      this.props.onChange(value);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions);
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  render () {
+    const { style, items, value } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='privacy-dropdown__dropdown' style={{ ...style, opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }} ref={this.setRef}>
+            {items.map(item =>
+              <div role='button' tabIndex='0' key={item.value} data-index={item.value} onKeyDown={this.handleClick} onClick={this.handleClick} className={classNames('privacy-dropdown__option', { active: item.value === value })}>
+                <div className='privacy-dropdown__option__icon'>
+                  <i className={`fa fa-fw fa-${item.icon}`} />
+                </div>
+
+                <div className='privacy-dropdown__option__content'>
+                  <strong>{item.text}</strong>
+                  {item.meta}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
+
+@injectIntl
+export default class PrivacyDropdown extends React.PureComponent {
+
+  static propTypes = {
+    isUserTouching: PropTypes.func,
+    isModalOpen: PropTypes.bool.isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    open: false,
+  };
+
+  handleToggle = () => {
+    if (this.props.isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        this.props.onModalOpen({
+          actions: this.options.map(option => ({ ...option, active: option.value === this.props.value })),
+          onClick: this.handleModalActionClick,
+        });
+      }
+    } else {
+      this.setState({ open: !this.state.open });
+    }
+  }
+
+  handleModalActionClick = (e) => {
+    e.preventDefault();
+
+    const { value } = this.options[e.currentTarget.getAttribute('data-index')];
+
+    this.props.onModalClose();
+    this.props.onChange(value);
+  }
+
+  handleKeyDown = e => {
+    switch(e.key) {
+    case 'Enter':
+      this.handleToggle();
+      break;
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleClose = () => {
+    this.setState({ open: false });
+  }
+
+  handleChange = value => {
+    this.props.onChange(value);
+  }
+
+  componentWillMount () {
+    const { intl: { formatMessage } } = this.props;
+
+    this.options = [
+      { icon: 'globe', value: 'public', text: formatMessage(messages.public_short), meta: formatMessage(messages.public_long) },
+      { icon: 'unlock-alt', value: 'unlisted', text: formatMessage(messages.unlisted_short), meta: formatMessage(messages.unlisted_long) },
+      { icon: 'lock', value: 'private', text: formatMessage(messages.private_short), meta: formatMessage(messages.private_long) },
+      { icon: 'envelope', value: 'direct', text: formatMessage(messages.direct_short), meta: formatMessage(messages.direct_long) },
+    ];
+  }
+
+  render () {
+    const { value, intl } = this.props;
+    const { open } = this.state;
+
+    const valueOption = this.options.find(item => item.value === value);
+
+    return (
+      <div className={classNames('privacy-dropdown', { active: open })} onKeyDown={this.handleKeyDown}>
+        <div className={classNames('privacy-dropdown__value', { active: this.options.indexOf(valueOption) === 0 })}>
+          <IconButton
+            className='privacy-dropdown__value-icon'
+            icon={valueOption.icon}
+            title={intl.formatMessage(messages.change_privacy)}
+            size={18}
+            expanded={open}
+            active={open}
+            inverted
+            onClick={this.handleToggle}
+            style={{ height: null, lineHeight: '27px' }}
+          />
+        </div>
+
+        <Overlay show={open} placement='bottom' target={this}>
+          <PrivacyDropdownMenu
+            items={this.options}
+            value={value}
+            onClose={this.handleClose}
+            onChange={this.handleChange}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/reply_indicator.js b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..9a8d10ceb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/reply_indicator.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'themes/glitch/components/avatar';
+import IconButton from 'themes/glitch/components/icon_button';
+import DisplayName from 'themes/glitch/components/display_name';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  cancel: { id: 'reply_indicator.cancel', defaultMessage: 'Cancel' },
+});
+
+@injectIntl
+export default class ReplyIndicator extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    onCancel: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleClick = () => {
+    this.props.onCancel();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const content  = { __html: status.get('contentHtml') };
+
+    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 account={status.get('account')} size={24} /></div>
+            <DisplayName account={status.get('account')} />
+          </a>
+        </div>
+
+        <div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search.js b/app/javascript/themes/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..c3218137f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285 }}>
+        <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+          {({ opacity, scaleX, scaleY }) => (
+            <div className='search-popout' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+              <h4><FormattedMessage id='search_popout.search_format' defaultMessage='Advanced search format' /></h4>
+
+              <ul>
+                <li><em>#example</em> <FormattedMessage id='search_popout.tips.hashtag' defaultMessage='hashtag' /></li>
+                <li><em>@username@domain</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.user' defaultMessage='user' /></li>
+                <li><em>URL</em> <FormattedMessage id='search_popout.tips.status' defaultMessage='status' /></li>
+              </ul>
+
+              <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+@injectIntl
+export default class Search extends React.PureComponent {
+
+  static 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,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  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();
+    } else if (e.key === 'Escape') {
+      document.querySelector('.ui').parentElement.focus();
+    }
+  }
+
+  noop () {
+
+  }
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+  }
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
+  render () {
+    const { intl, value, submitted } = this.props;
+    const { expanded } = this.state;
+    const hasValue = value.length > 0 || submitted;
+
+    return (
+      <div className='search'>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.placeholder)}</span>
+          <input
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyDown}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+
+        <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>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/search_results.js b/app/javascript/themes/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..3fdafa5f3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/search_results.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+  };
+
+  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 key={hashtag} 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/text_icon_button.js b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..9c8ffab1f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class TextIconButton extends React.PureComponent {
+
+  static propTypes = {
+    label: PropTypes.string.isRequired,
+    title: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    ariaControls: PropTypes.string,
+  };
+
+  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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload.js b/app/javascript/themes/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..ded376ada
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload.js
@@ -0,0 +1,96 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'themes/glitch/components/icon_button';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  undo: { id: 'upload_form.undo', defaultMessage: 'Undo' },
+  description: { id: 'upload_form.description', defaultMessage: 'Describe for the visually impaired' },
+});
+
+@injectIntl
+export default class Upload extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onDescriptionChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    hovered: false,
+    focused: false,
+    dirtyDescription: null,
+  };
+
+  handleUndoClick = () => {
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleInputChange = e => {
+    this.setState({ dirtyDescription: e.target.value });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  handleInputFocus = () => {
+    this.setState({ focused: true });
+  }
+
+  handleInputBlur = () => {
+    const { dirtyDescription } = this.state;
+
+    this.setState({ focused: false, dirtyDescription: null });
+
+    if (dirtyDescription !== null) {
+      this.props.onDescriptionChange(this.props.media.get('id'), dirtyDescription);
+    }
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const active          = this.state.hovered || this.state.focused;
+    const description     = this.state.dirtyDescription || media.get('description') || '';
+
+    return (
+      <div className='compose-form__upload' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12 }) }}>
+          {({ scale }) => (
+            <div className='compose-form__upload-thumbnail' style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})` }}>
+              <IconButton icon='times' title={intl.formatMessage(messages.undo)} size={36} onClick={this.handleUndoClick} />
+
+              <div className={classNames('compose-form__upload-description', { active })}>
+                <label>
+                  <span style={{ display: 'none' }}>{intl.formatMessage(messages.description)}</span>
+
+                  <input
+                    placeholder={intl.formatMessage(messages.description)}
+                    type='text'
+                    value={description}
+                    maxLength={420}
+                    onFocus={this.handleInputFocus}
+                    onChange={this.handleInputChange}
+                    onBlur={this.handleInputBlur}
+                  />
+                </label>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_button.js b/app/javascript/themes/glitch/features/compose/components/upload_button.js
new file mode 100644
index 000000000..d7742adfe
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_button.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import IconButton from 'themes/glitch/components/icon_button';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+const messages = defineMessages({
+  upload: { id: 'upload_button.label', defaultMessage: 'Add media' },
+});
+
+const makeMapStateToProps = () => {
+  const mapStateToProps = state => ({
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']),
+  });
+
+  return mapStateToProps;
+};
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class UploadButton extends ImmutablePureComponent {
+
+  static propTypes = {
+    disabled: PropTypes.bool,
+    onSelectFile: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    resetFileKey: PropTypes.number,
+    acceptContentTypes: ImmutablePropTypes.listOf(PropTypes.string).isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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, acceptContentTypes } = 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} />
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.upload)}</span>
+          <input
+            key={resetFileKey}
+            ref={this.setRef}
+            type='file'
+            multiple={false}
+            accept={acceptContentTypes.toArray().join(',')}
+            onChange={this.handleChange}
+            disabled={disabled}
+            style={{ display: 'none' }}
+          />
+        </label>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_form.js b/app/javascript/themes/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..b7f112205
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_form.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import UploadProgressContainer from '../containers/upload_progress_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import UploadContainer from '../containers/upload_container';
+
+export default class UploadForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='compose-form__upload-wrapper'>
+        <UploadProgressContainer />
+
+        <div className='compose-form__uploads-wrapper'>
+          {mediaIds.map(id => (
+            <UploadContainer id={id} key={id} />
+          ))}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/upload_progress.js b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..b923d0a22
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,42 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+  };
+
+  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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/components/warning.js b/app/javascript/themes/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..82df55a31
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+export default class Warning extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { message } = this.props;
+
+    return (
+      <Motion defaultStyle={{ opacity: 0, scaleX: 0.85, scaleY: 0.75 }} style={{ opacity: spring(1, { damping: 35, stiffness: 400 }), scaleX: spring(1, { damping: 35, stiffness: 400 }), scaleY: spring(1, { damping: 35, stiffness: 400 }) }}>
+        {({ opacity, scaleX, scaleY }) => (
+          <div className='compose-form__warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js
new file mode 100644
index 000000000..9f168942a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/advanced_options_container.js
@@ -0,0 +1,20 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import { toggleComposeAdvancedOption } from 'themes/glitch/actions/compose';
+import ComposeAdvancedOptions from '../components/advanced_options';
+
+const mapStateToProps = state => ({
+  values: state.getIn(['compose', 'advanced_options']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (option) {
+    dispatch(toggleComposeAdvancedOption(option));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ComposeAdvancedOptions);
diff --git a/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/themes/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..96eb70c18
--- /dev/null
+++ b/app/javascript/themes/glitch/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 'themes/glitch/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/themes/glitch/features/compose/containers/compose_form_container.js b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..7afa988f1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,71 @@
+import { connect } from 'react-redux';
+import ComposeForm from '../components/compose_form';
+import { changeComposeVisibility, uploadCompose } from 'themes/glitch/actions/compose';
+import {
+  changeCompose,
+  submitCompose,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+  changeComposeSpoilerText,
+  insertEmojiCompose,
+} from 'themes/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+  text: state.getIn(['compose', 'text']),
+  suggestion_token: state.getIn(['compose', 'suggestion_token']),
+  suggestions: state.getIn(['compose', 'suggestions']),
+  advanced_options: state.getIn(['compose', 'advanced_options']),
+  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']),
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+  settings: state.get('local_settings'),
+  filesAttached: state.getIn(['compose', 'media_attachments']).size > 0,
+});
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChange (text) {
+    dispatch(changeCompose(text));
+  },
+
+  onPrivacyChange (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  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/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js
new file mode 100644
index 000000000..55a13bd65
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/emoji_picker_dropdown_container.js
@@ -0,0 +1,82 @@
+import { connect } from 'react-redux';
+import EmojiPickerDropdown from '../components/emoji_picker_dropdown';
+import { changeSetting } from 'themes/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'themes/glitch/actions/emojis';
+
+const perLine = 8;
+const lines   = 2;
+
+const DEFAULTS = [
+  '+1',
+  'grinning',
+  'kissing_heart',
+  'heart_eyes',
+  'laughing',
+  'stuck_out_tongue_winking_eye',
+  'sweat_smile',
+  'joy',
+  'yum',
+  'disappointed',
+  'thinking_face',
+  'weary',
+  'sob',
+  'sunglasses',
+  'heart',
+  'ok_hand',
+];
+
+const getFrequentlyUsedEmojis = createSelector([
+  state => state.getIn(['settings', 'frequentlyUsedEmojis'], ImmutableMap()),
+], emojiCounters => {
+  let emojis = emojiCounters
+    .keySeq()
+    .sort((a, b) => emojiCounters.get(a) - emojiCounters.get(b))
+    .reverse()
+    .slice(0, perLine * lines)
+    .toArray();
+
+  if (emojis.length < DEFAULTS.length) {
+    emojis = emojis.concat(DEFAULTS.slice(0, DEFAULTS.length - emojis.length));
+  }
+
+  return emojis;
+});
+
+const getCustomEmojis = createSelector([
+  state => state.get('custom_emojis'),
+], emojis => emojis.filter(e => e.get('visible_in_picker')).sort((a, b) => {
+  const aShort = a.get('shortcode').toLowerCase();
+  const bShort = b.get('shortcode').toLowerCase();
+
+  if (aShort < bShort) {
+    return -1;
+  } else if (aShort > bShort ) {
+    return 1;
+  } else {
+    return 0;
+  }
+}));
+
+const mapStateToProps = state => ({
+  custom_emojis: getCustomEmojis(state),
+  skinTone: state.getIn(['settings', 'skinTone']),
+  frequentlyUsedEmojis: getFrequentlyUsedEmojis(state),
+});
+
+const mapDispatchToProps = (dispatch, { onPickEmoji }) => ({
+  onSkinTone: skinTone => {
+    dispatch(changeSetting(['skinTone'], skinTone));
+  },
+
+  onPickEmoji: emoji => {
+    dispatch(useEmoji(emoji));
+
+    if (onPickEmoji) {
+      onPickEmoji(emoji);
+    }
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(EmojiPickerDropdown);
diff --git a/app/javascript/themes/glitch/features/compose/containers/navigation_container.js b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..b6d737b46
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/navigation_container.js
@@ -0,0 +1,11 @@
+import { connect }   from 'react-redux';
+import NavigationBar from '../components/navigation_bar';
+import { me } from 'themes/glitch/util/initial_state';
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js
new file mode 100644
index 000000000..9636ceab2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/privacy_dropdown_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import PrivacyDropdown from '../components/privacy_dropdown';
+import { changeComposeVisibility } from 'themes/glitch/actions/compose';
+import { openModal, closeModal } from 'themes/glitch/actions/modal';
+import { isUserTouching } from 'themes/glitch/util/is_mobile';
+
+const mapStateToProps = state => ({
+  isModalOpen: state.get('modal').modalType === 'ACTIONS',
+  value: state.getIn(['compose', 'privacy']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  isUserTouching,
+  onModalOpen: props => dispatch(openModal('ACTIONS', props)),
+  onModalClose: () => dispatch(closeModal()),
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PrivacyDropdown);
diff --git a/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..6dcabb3cd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,24 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'themes/glitch/actions/compose';
+import { makeGetStatus } from 'themes/glitch/selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = state => ({
+    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/themes/glitch/features/compose/containers/search_container.js b/app/javascript/themes/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..a450d27e7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'themes/glitch/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/themes/glitch/features/compose/containers/search_results_container.js b/app/javascript/themes/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..16d95d417
--- /dev/null
+++ b/app/javascript/themes/glitch/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/themes/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..a710dd104
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import IconButton from 'themes/glitch/components/icon_button';
+import { changeComposeSensitivity } from 'themes/glitch/actions/compose';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+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']),
+  disabled: state.getIn(['compose', 'spoiler']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  static propTypes = {
+    visible: PropTypes.bool,
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { visible, active, disabled, onClick, intl } = this.props;
+
+    return (
+      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
+        {({ scale }) => {
+          const icon = active ? 'eye-slash' : 'eye';
+          const className = classNames('compose-form__sensitive-button', {
+            'compose-form__sensitive-button--visible': visible,
+          });
+          return (
+            <div className={className} style={{ transform: `scale(${scale})` }}>
+              <IconButton
+                className='compose-form__sensitive-button__icon'
+                title={intl.formatMessage(messages.title)}
+                icon={icon}
+                onClick={onClick}
+                size={18}
+                active={active}
+                disabled={disabled}
+                style={{ lineHeight: null, height: null }}
+                inverted
+              />
+            </div>
+          );
+        }}
+      </Motion>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js b/app/javascript/themes/glitch/features/compose/containers/spoiler_button_container.js
new file mode 100644
index 000000000..160e71ba9
--- /dev/null
+++ b/app/javascript/themes/glitch/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 'themes/glitch/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/themes/glitch/features/compose/containers/upload_button_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_button_container.js
new file mode 100644
index 000000000..f332eae1a
--- /dev/null
+++ b/app/javascript/themes/glitch/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 'themes/glitch/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/themes/glitch/features/compose/containers/upload_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..eea514bf5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,21 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose, changeUploadCompose } from 'themes/glitch/actions/compose';
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onUndo: id => {
+    dispatch(undoUploadCompose(id));
+  },
+
+  onDescriptionChange: (id, description) => {
+    dispatch(changeUploadCompose(id, description));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/upload_form_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import UploadForm from '../components/upload_form';
+
+const mapStateToProps = state => ({
+  mediaIds: state.getIn(['compose', 'media_attachments']).map(item => item.get('id')),
+});
+
+export default connect(mapStateToProps)(UploadForm);
diff --git a/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/themes/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..0cfee96da
--- /dev/null
+++ b/app/javascript/themes/glitch/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 => ({
+  active: state.getIn(['compose', 'is_uploading']),
+  progress: state.getIn(['compose', 'progress']),
+});
+
+export default connect(mapStateToProps)(UploadProgress);
diff --git a/app/javascript/themes/glitch/features/compose/containers/warning_container.js b/app/javascript/themes/glitch/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..225d6a1dd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/containers/warning_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import Warning from '../components/warning';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+});
+
+const WarningWrapper = ({ needsLockWarning }) => {
+  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> }} />} />;
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLockWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/themes/glitch/features/compose/index.js b/app/javascript/themes/glitch/features/compose/index.js
new file mode 100644
index 000000000..3fcaf416f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/compose/index.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ComposeFormContainer from './containers/compose_form_container';
+import NavigationContainer from './containers/navigation_container';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import { mountCompose, unmountCompose } from 'themes/glitch/actions/compose';
+import { openModal } from 'themes/glitch/actions/modal';
+import { changeLocalSetting } from 'themes/glitch/actions/local_settings';
+import { Link } from 'react-router-dom';
+import { injectIntl, defineMessages } from 'react-intl';
+import SearchContainer from './containers/search_container';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { changeComposing } from 'themes/glitch/actions/compose';
+
+const messages = defineMessages({
+  start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+  public: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+  community: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+  logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
+});
+
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+  showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Compose extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columns: ImmutablePropTypes.list.isRequired,
+    multiColumn: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.dispatch(mountCompose());
+  }
+
+  componentWillUnmount () {
+    this.props.dispatch(unmountCompose());
+  }
+
+  onLayoutClick = (e) => {
+    const layout = e.currentTarget.getAttribute('data-mastodon-layout');
+    this.props.dispatch(changeLocalSetting(['layout'], layout));
+    e.preventDefault();
+  }
+
+  openSettings = () => {
+    this.props.dispatch(openModal('SETTINGS', {}));
+  }
+
+  onFocus = () => {
+    this.props.dispatch(changeComposing(true));
+  }
+
+  onBlur = () => {
+    this.props.dispatch(changeComposing(false));
+  }
+
+  render () {
+    const { multiColumn, showSearch, intl } = this.props;
+
+    let header = '';
+
+    if (multiColumn) {
+      const { columns } = this.props;
+      header = (
+        <nav className='drawer__header'>
+          <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link>
+          {!columns.some(column => column.get('id') === 'HOME') && (
+            <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
+          )}
+          {!columns.some(column => column.get('id') === 'NOTIFICATIONS') && (
+            <Link to='/notifications' className='drawer__tab' title={intl.formatMessage(messages.notifications)} aria-label={intl.formatMessage(messages.notifications)}><i role='img' className='fa fa-fw fa-bell' /></Link>
+          )}
+          {!columns.some(column => column.get('id') === 'COMMUNITY') && (
+            <Link to='/timelines/public/local' className='drawer__tab' title={intl.formatMessage(messages.community)} aria-label={intl.formatMessage(messages.community)}><i role='img' className='fa fa-fw fa-users' /></Link>
+          )}
+          {!columns.some(column => column.get('id') === 'PUBLIC') && (
+            <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><i role='img' className='fa fa-fw fa-globe' /></Link>
+          )}
+          <a onClick={this.openSettings} role='button' tabIndex='0' className='drawer__tab' title={intl.formatMessage(messages.settings)} aria-label={intl.formatMessage(messages.settings)}><i role='img' className='fa fa-fw fa-cogs' /></a>
+          <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><i role='img' className='fa fa-fw fa-sign-out' /></a>
+        </nav>
+      );
+    }
+
+
+
+    return (
+      <div className='drawer'>
+        {header}
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner scrollable optionally-scrollable' onFocus={this.onFocus}>
+            <NavigationContainer onClose={this.onBlur} />
+            <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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..2a40c65a5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/direct_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'direct']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['direct', ...key], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/direct_timeline/index.js b/app/javascript/themes/glitch/features/direct_timeline/index.js
new file mode 100644
index 000000000..6b29cf94d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/direct_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+  refreshDirectTimeline,
+  expandDirectTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectDirectStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class DirectTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECT', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshDirectTimeline());
+    this.disconnect = dispatch(connectDirectStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandDirectTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='envelope'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.direct' defaultMessage="You don't have any direct messages yet. When you send or receive one, it will show up here." />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/favourited_statuses/index.js b/app/javascript/themes/glitch/features/favourited_statuses/index.js
new file mode 100644
index 000000000..80345e0e2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/favourited_statuses/index.js
@@ -0,0 +1,94 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'themes/glitch/actions/favourites';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import StatusList from 'themes/glitch/components/status_list';
+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']),
+  hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavouritedStatuses());
+  }
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('FAVOURITES', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleScrollToBottom = () => {
+    this.props.dispatch(expandFavouritedStatuses());
+  }
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='favourites'>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusList
+          trackScroll={!pinned}
+          statusIds={statusIds}
+          scrollKey={`favourited_statuses-${columnId}`}
+          hasMore={hasMore}
+          onScrollToBottom={this.handleScrollToBottom}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/favourites/index.js b/app/javascript/themes/glitch/features/favourites/index.js
new file mode 100644
index 000000000..d7b8ac3b1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/favourites/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { fetchFavourites } from 'themes/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchFavourites(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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..ce386d888
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/components/account_authorize.js
@@ -0,0 +1,49 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Permalink from 'themes/glitch/components/permalink';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import IconButton from 'themes/glitch/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' },
+});
+
+@injectIntl
+export default class AccountAuthorize extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onAuthorize: PropTypes.func.isRequired,
+    onReject: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, account, onAuthorize, onReject } = this.props;
+    const content = { __html: account.get('note_emojified') };
+
+    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 account={account} 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..78ae77eee
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from 'themes/glitch/actions/accounts';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, props) => ({
+    account: getAccount(state, props.id),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(id));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(id));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(AccountAuthorize);
diff --git a/app/javascript/themes/glitch/features/follow_requests/index.js b/app/javascript/themes/glitch/features/follow_requests/index.js
new file mode 100644
index 000000000..3f44f518a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/follow_requests/index.js
@@ -0,0 +1,71 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from 'themes/glitch/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']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class FollowRequests extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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 name='follow-requests'>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    return (
+      <Column name='follow-requests' 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/followers/index.js b/app/javascript/themes/glitch/features/followers/index.js
new file mode 100644
index 000000000..d586bf41d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/followers/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers,
+} from 'themes/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'themes/glitch/components/load_more';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Followers extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowers(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowers(nextProps.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+      this.props.dispatch(expandFollowers(this.props.params.accountId));
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.dispatch(expandFollowers(this.props.params.accountId));
+  }
+
+  render () {
+    const { accountIds, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    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}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/following/index.js b/app/javascript/themes/glitch/features/following/index.js
new file mode 100644
index 000000000..c306faf21
--- /dev/null
+++ b/app/javascript/themes/glitch/features/following/index.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing,
+} from 'themes/glitch/actions/accounts';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import HeaderContainer from 'themes/glitch/features/account_timeline/containers/header_container';
+import LoadMore from 'themes/glitch/components/load_more';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
+});
+
+@connect(mapStateToProps)
+export default class Following extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(fetchFollowing(this.props.params.accountId));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchFollowing(nextProps.params.accountId));
+    }
+  }
+
+  handleScroll = (e) => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+
+    if (scrollTop === scrollHeight - clientHeight && this.props.hasMore) {
+      this.props.dispatch(expandFollowing(this.props.params.accountId));
+    }
+  }
+
+  handleLoadMore = (e) => {
+    e.preventDefault();
+    this.props.dispatch(expandFollowing(this.props.params.accountId));
+  }
+
+  render () {
+    const { accountIds, hasMore } = this.props;
+
+    let loadMore = null;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    if (hasMore) {
+      loadMore = <LoadMore onClick={this.handleLoadMore} />;
+    }
+
+    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}
+            </div>
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/generic_not_found/index.js b/app/javascript/themes/glitch/features/generic_not_found/index.js
new file mode 100644
index 000000000..ccd2b87b2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from 'themes/glitch/features/ui/components/column';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/themes/glitch/features/getting_started/index.js b/app/javascript/themes/glitch/features/getting_started/index.js
new file mode 100644
index 000000000..74b019cf1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/getting_started/index.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnLink from 'themes/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'themes/glitch/features/ui/components/column_subheading';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { openModal } from 'themes/glitch/actions/modal';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me } from 'themes/glitch/util/initial_state';
+
+const messages = defineMessages({
+  heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
+  notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
+  public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
+  navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
+  settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
+  community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
+  direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
+  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' },
+  show_me_around: { id: 'getting_started.onboarding', defaultMessage: 'Show me around' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
+});
+
+const mapStateToProps = state => ({
+  myAccount: state.getIn(['accounts', me]),
+  columns: state.getIn(['settings', 'columns']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class GettingStarted extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map.isRequired,
+    columns: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  openSettings = () => {
+    this.props.dispatch(openModal('SETTINGS', {}));
+  }
+
+  openOnboardingModal = (e) => {
+    e.preventDefault();
+    this.props.dispatch(openModal('ONBOARDING'));
+  }
+
+  render () {
+    const { intl, myAccount, columns, multiColumn } = this.props;
+
+    let navItems = [];
+
+    if (multiColumn) {
+      if (!columns.find(item => item.get('id') === 'HOME')) {
+        navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
+        navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} to='/notifications' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
+        navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />);
+      }
+
+      if (!columns.find(item => item.get('id') === 'PUBLIC')) {
+        navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />);
+      }
+    }
+
+    if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
+      navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
+    }
+
+    navItems = navItems.concat([
+      <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
+      <ColumnLink key='6' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
+    ]);
+
+    if (myAccount.get('locked')) {
+      navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+    }
+
+    navItems = navItems.concat([
+      <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
+      <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
+    ]);
+
+    return (
+      <Column name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
+        <div className='scrollable optionally-scrollable'>
+          <div className='getting-started__wrapper'>
+            <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
+            {navItems}
+            <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
+            <ColumnLink icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
+            <ColumnLink icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
+            <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
+            <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={this.openSettings} />
+            <ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
+          </div>
+
+          <div className='getting-started__footer'>
+            <div className='static-content getting-started'>
+              <p>
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.faq' defaultMessage='FAQ' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' />
+                </a>&nbsp;•&nbsp;
+                <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'>
+                  <FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' />
+                </a>
+              </p>
+              <p>
+                <FormattedMessage
+                  id='getting_started.open_source_notice'
+                  defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
+                  values={{
+                    github: <a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a>,
+                    Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a>,
+                  }}
+                />
+              </p>
+            </div>
+          </div>
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..a878931b3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/hashtag_timeline/index.js
@@ -0,0 +1,118 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { FormattedMessage } from 'react-intl';
+import { connectHashtagStream } from 'themes/glitch/actions/streaming';
+
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    dispatch: PropTypes.func.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HASHTAG', { id: this.props.params.id }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  _subscribe (dispatch, id) {
+    this.disconnect = dispatch(connectHashtagStream(id));
+  }
+
+  _unsubscribe () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(refreshHashtagTimeline(id));
+    this._subscribe(dispatch, id);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.id !== this.props.params.id) {
+      this.props.dispatch(refreshHashtagTimeline(nextProps.params.id));
+      this._unsubscribe();
+      this._subscribe(this.props.dispatch, nextProps.params.id);
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.params.id));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='hashtag'>
+        <ColumnHeader
+          icon='hashtag'
+          active={hasUnread}
+          title={id}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+        />
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`hashtag_timeline-${columnId}`}
+          timelineId={`hashtag:${id}`}
+          loadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..533da6c36
--- /dev/null
+++ b/app/javascript/themes/glitch/features/home_timeline/components/column_settings.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from 'themes/glitch/features/notifications/components/setting_toggle';
+import SettingText from 'themes/glitch/components/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' },
+});
+
+@injectIntl
+export default class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <span className='column-settings__section'><FormattedMessage id='home.column_settings.basic' defaultMessage='Basic' /></span>
+
+        <div className='column-settings__row'>
+          <SettingToggle prefix='home_timeline' 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 prefix='home_timeline' 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 prefix='home_timeline' settings={settings} settingKey={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..a0062f564
--- /dev/null
+++ b/app/javascript/themes/glitch/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 'themes/glitch/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/themes/glitch/features/home_timeline/index.js b/app/javascript/themes/glitch/features/home_timeline/index.js
new file mode 100644
index 000000000..8a65891cd
--- /dev/null
+++ b/app/javascript/themes/glitch/features/home_timeline/index.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from 'themes/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class HomeTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('HOME', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHomeTimeline());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='home'>
+        <ColumnHeader
+          icon='home'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`home_timeline-${columnId}`}
+          loadMore={this.handleLoadMore}
+          timelineId='home'
+          emptyMessage={<FormattedMessage id='empty_column.home' defaultMessage='Your home timeline is empty! 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/index.js b/app/javascript/themes/glitch/features/local_settings/index.js
new file mode 100644
index 000000000..6c5d51413
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/index.js
@@ -0,0 +1,68 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+
+//  Our imports
+import LocalSettingsPage from './page';
+import LocalSettingsNavigation from './navigation';
+import { closeModal } from 'themes/glitch/actions/modal';
+import { changeLocalSetting } from 'themes/glitch/actions/local_settings';
+
+//  Stylesheet imports
+import './style.scss';
+
+const mapStateToProps = state => ({
+  settings: state.get('local_settings'),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onChange (setting, value) {
+    dispatch(changeLocalSetting(setting, value));
+  },
+  onClose () {
+    dispatch(closeModal());
+  },
+});
+
+class LocalSettings extends React.PureComponent {
+
+  static propTypes = {
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    settings: ImmutablePropTypes.map.isRequired,
+  };
+
+  state = {
+    currentIndex: 0,
+  };
+
+  navigateTo = (index) =>
+    this.setState({ currentIndex: +index });
+
+  render () {
+
+    const { navigateTo } = this;
+    const { onChange, onClose, settings } = this.props;
+    const { currentIndex } = this.state;
+
+    return (
+      <div className='glitch modal-root__modal local-settings'>
+        <LocalSettingsNavigation
+          index={currentIndex}
+          onClose={onClose}
+          onNavigate={navigateTo}
+        />
+        <LocalSettingsPage
+          index={currentIndex}
+          onChange={onChange}
+          settings={settings}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(LocalSettings);
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/index.js
new file mode 100644
index 000000000..fa35e83c7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/index.js
@@ -0,0 +1,74 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports
+import LocalSettingsNavigationItem from './item';
+
+//  Stylesheet imports
+import './style.scss';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  general: {  id: 'settings.general', defaultMessage: 'General' },
+  collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
+  media: { id: 'settings.media', defaultMessage: 'Media' },
+  preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
+  close: { id: 'settings.close', defaultMessage: 'Close' },
+});
+
+@injectIntl
+export default class LocalSettingsNavigation extends React.PureComponent {
+
+  static propTypes = {
+    index      : PropTypes.number,
+    intl       : PropTypes.object.isRequired,
+    onClose    : PropTypes.func.isRequired,
+    onNavigate : PropTypes.func.isRequired,
+  };
+
+  render () {
+
+    const { index, intl, onClose, onNavigate } = this.props;
+
+    return (
+      <nav className='glitch local-settings__navigation'>
+        <LocalSettingsNavigationItem
+          active={index === 0}
+          index={0}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.general)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 1}
+          index={1}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.collapsed)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 2}
+          index={2}
+          onNavigate={onNavigate}
+          title={intl.formatMessage(messages.media)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 3}
+          href='/settings/preferences'
+          index={3}
+          icon='cog'
+          title={intl.formatMessage(messages.preferences)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 4}
+          className='close'
+          index={4}
+          onNavigate={onClose}
+          title={intl.formatMessage(messages.close)}
+        />
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js
new file mode 100644
index 000000000..a352d5fb2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/index.js
@@ -0,0 +1,69 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+//  Stylesheet imports
+import './style.scss';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    className: PropTypes.string,
+    href: PropTypes.string,
+    icon: PropTypes.string,
+    index: PropTypes.number.isRequired,
+    onNavigate: PropTypes.func,
+    title: PropTypes.string,
+  };
+
+  handleClick = (e) => {
+    const { index, onNavigate } = this.props;
+    if (onNavigate) {
+      onNavigate(index);
+      e.preventDefault();
+    }
+  }
+
+  render () {
+    const { handleClick } = this;
+    const {
+      active,
+      className,
+      href,
+      icon,
+      onNavigate,
+      title,
+    } = this.props;
+
+    const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
+      active,
+    }, className);
+
+    const iconElem = icon ? <i className={`fa fa-fw fa-${icon}`} /> : null;
+
+    if (href) return (
+      <a
+        href={href}
+        className={finalClassName}
+      >
+        {iconElem} {title}
+      </a>
+    );
+    else if (onNavigate) return (
+      <a
+        onClick={handleClick}
+        role='button'
+        tabIndex='0'
+        className={finalClassName}
+      >
+        {iconElem} {title}
+      </a>
+    );
+    else return null;
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss
new file mode 100644
index 000000000..7f7371993
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/item/style.scss
@@ -0,0 +1,27 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__navigation__item {
+  display: block;
+  padding: 15px 20px;
+  color: inherit;
+  background: $primary-text-color;
+  border-bottom: 1px $ui-primary-color solid;
+  cursor: pointer;
+  text-decoration: none;
+  outline: none;
+  transition: background .3s;
+
+  &:hover {
+    background: $ui-secondary-color;
+  }
+
+  &.active {
+    background: $ui-highlight-color;
+    color: $primary-text-color;
+  }
+
+  &.close, &.close:hover {
+    background: $error-value-color;
+    color: $primary-text-color;
+  }
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/navigation/style.scss b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss
new file mode 100644
index 000000000..0336f943b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/navigation/style.scss
@@ -0,0 +1,10 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__navigation {
+  background: $primary-text-color;
+  color: $ui-base-color;
+  width: 200px;
+  font-size: 15px;
+  line-height: 20px;
+  overflow-y: auto;
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/index.js b/app/javascript/themes/glitch/features/local_settings/page/index.js
new file mode 100644
index 000000000..498230f7b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/index.js
@@ -0,0 +1,212 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+
+//  Our imports
+import LocalSettingsPageItem from './item';
+
+//  Stylesheet imports
+import './style.scss';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  layout_auto: {  id: 'layout.auto', defaultMessage: 'Auto' },
+  layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+  layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
+});
+
+@injectIntl
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    index    : PropTypes.number,
+    intl     : PropTypes.object.isRequired,
+    onChange : PropTypes.func.isRequired,
+    settings : ImmutablePropTypes.map.isRequired,
+  };
+
+  pages = [
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page general'>
+        <h1><FormattedMessage id='settings.general' defaultMessage='General' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['layout']}
+          id='mastodon-settings--layout'
+          options={[
+            { value: 'auto', message: intl.formatMessage(messages.layout_auto) },
+            { value: 'multiple', message: intl.formatMessage(messages.layout_desktop) },
+            { value: 'single', message: intl.formatMessage(messages.layout_mobile) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.layout' defaultMessage='Layout:' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['stretch']}
+          id='mastodon-settings--stretch'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.wide_view' defaultMessage='Wide view (Desktop mode only)' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['navbar_under']}
+          id='mastodon-settings--navbar_under'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.navbar_under' defaultMessage='Navbar at the bottom (Mobile only)' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['side_arm']}
+            id='mastodon-settings--side_arm'
+            options={[
+              { value: 'none', message: intl.formatMessage(messages.side_arm_none) },
+              { value: 'direct', message: intl.formatMessage({ id: 'privacy.direct.short' }) },
+              { value: 'private', message: intl.formatMessage({ id: 'privacy.private.short' }) },
+              { value: 'unlisted', message: intl.formatMessage({ id: 'privacy.unlisted.short' }) },
+              { value: 'public', message: intl.formatMessage({ id: 'privacy.public.short' }) },
+            ]}
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.side_arm' defaultMessage='Secondary toot button:' />
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ onChange, settings }) => (
+      <div className='glitch local-settings__page collapsed'>
+        <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['collapsed', 'enabled']}
+          id='mastodon-settings--collapsed-enabled'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.auto_collapse' defaultMessage='Automatic collapsing' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'all']}
+            id='mastodon-settings--collapsed-auto-all'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_all' defaultMessage='Everything' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'notifications']}
+            id='mastodon-settings--collapsed-auto-notifications'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_notifications' defaultMessage='Notifications' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'lengthy']}
+            id='mastodon-settings--collapsed-auto-lengthy'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_lengthy' defaultMessage='Lengthy toots' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'reblogs']}
+            id='mastodon-settings--collapsed-auto-reblogs'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_reblogs' defaultMessage='Boosts' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'replies']}
+            id='mastodon-settings--collapsed-auto-replies'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_replies' defaultMessage='Replies' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'auto', 'media']}
+            id='mastodon-settings--collapsed-auto-media'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+            dependsOnNot={[['collapsed', 'auto', 'all']]}
+          >
+            <FormattedMessage id='settings.auto_collapse_media' defaultMessage='Toots with media' />
+          </LocalSettingsPageItem>
+        </section>
+        <section>
+          <h2><FormattedMessage id='settings.image_backgrounds' defaultMessage='Image backgrounds' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'backgrounds', 'user_backgrounds']}
+            id='mastodon-settings--collapsed-user-backgrouns'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.image_backgrounds_users' defaultMessage='Give collapsed toots an image background' />
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['collapsed', 'backgrounds', 'preview_images']}
+            id='mastodon-settings--collapsed-preview-images'
+            onChange={onChange}
+            dependsOn={[['collapsed', 'enabled']]}
+          >
+            <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ onChange, settings }) => (
+      <div className='glitch local-settings__page media'>
+        <h1><FormattedMessage id='settings.media' defaultMessage='Media' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'letterbox']}
+          id='mastodon-settings--media-letterbox'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_letterbox' defaultMessage='Letterbox media' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'fullwidth']}
+          id='mastodon-settings--media-fullwidth'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_fullwidth' defaultMessage='Full-width media previews' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
+  ];
+
+  render () {
+    const { pages } = this;
+    const { index, intl, onChange, settings } = this.props;
+    const CurrentPage = pages[index] || pages[0];
+
+    return <CurrentPage intl={intl} onChange={onChange} settings={settings} />;
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/index.js b/app/javascript/themes/glitch/features/local_settings/page/item/index.js
new file mode 100644
index 000000000..37e28c084
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/item/index.js
@@ -0,0 +1,90 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  Stylesheet imports
+import './style.scss';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPageItem extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.element.isRequired,
+    dependsOn: PropTypes.array,
+    dependsOnNot: PropTypes.array,
+    id: PropTypes.string.isRequired,
+    item: PropTypes.array.isRequired,
+    onChange: PropTypes.func.isRequired,
+    options: PropTypes.arrayOf(PropTypes.shape({
+      value: PropTypes.string.isRequired,
+      message: PropTypes.string.isRequired,
+    })),
+    settings: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleChange = e => {
+    const { target } = e;
+    const { item, onChange, options } = this.props;
+    if (options && options.length > 0) onChange(item, target.value);
+    else onChange(item, target.checked);
+  }
+
+  render () {
+    const { handleChange } = this;
+    const { settings, item, id, options, children, dependsOn, dependsOnNot } = this.props;
+    let enabled = true;
+
+    if (dependsOn) {
+      for (let i = 0; i < dependsOn.length; i++) {
+        enabled = enabled && settings.getIn(dependsOn[i]);
+      }
+    }
+    if (dependsOnNot) {
+      for (let i = 0; i < dependsOnNot.length; i++) {
+        enabled = enabled && !settings.getIn(dependsOnNot[i]);
+      }
+    }
+
+    if (options && options.length > 0) {
+      const currentValue = settings.getIn(item);
+      const optionElems = options && options.length > 0 && options.map((opt) => (
+        <option
+          key={opt.value}
+          value={opt.value}
+        >
+          {opt.message}
+        </option>
+      ));
+      return (
+        <label className='glitch local-settings__page__item' htmlFor={id}>
+          <p>{children}</p>
+          <p>
+            <select
+              id={id}
+              disabled={!enabled}
+              onBlur={handleChange}
+              onChange={handleChange}
+              value={currentValue}
+            >
+              {optionElems}
+            </select>
+          </p>
+        </label>
+      );
+    } else return (
+      <label className='glitch local-settings__page__item' htmlFor={id}>
+        <input
+          id={id}
+          type='checkbox'
+          checked={settings.getIn(item)}
+          onChange={handleChange}
+          disabled={!enabled}
+        />
+        {children}
+      </label>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/item/style.scss b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss
new file mode 100644
index 000000000..b2d8f7185
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/item/style.scss
@@ -0,0 +1,7 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__page__item {
+  select {
+    margin-bottom: 5px;
+  }
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/page/style.scss b/app/javascript/themes/glitch/features/local_settings/page/style.scss
new file mode 100644
index 000000000..e9eedcad0
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/page/style.scss
@@ -0,0 +1,9 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings__page {
+  display: block;
+  flex: auto;
+  padding: 15px 20px 15px 20px;
+  width: 360px;
+  overflow-y: auto;
+}
diff --git a/app/javascript/themes/glitch/features/local_settings/style.scss b/app/javascript/themes/glitch/features/local_settings/style.scss
new file mode 100644
index 000000000..765294607
--- /dev/null
+++ b/app/javascript/themes/glitch/features/local_settings/style.scss
@@ -0,0 +1,34 @@
+@import 'styles/mastodon/variables';
+
+.glitch.local-settings {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  background: $ui-secondary-color;
+  color: $ui-base-color;
+  border-radius: 8px;
+  height: 80vh;
+  width: 80vw;
+  max-width: 740px;
+  max-height: 450px;
+  overflow: hidden;
+
+  label {
+    display: block;
+  }
+
+  h1 {
+    font-size: 18px;
+    font-weight: 500;
+    line-height: 24px;
+    margin-bottom: 20px;
+  }
+
+  h2 {
+    font-size: 15px;
+    font-weight: 500;
+    line-height: 20px;
+    margin-top: 20px;
+    margin-bottom: 10px;
+  }
+}
diff --git a/app/javascript/themes/glitch/features/mutes/index.js b/app/javascript/themes/glitch/features/mutes/index.js
new file mode 100644
index 000000000..1158b8262
--- /dev/null
+++ b/app/javascript/themes/glitch/features/mutes/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { ScrollContainer } from 'react-router-scroll-4';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import { fetchMutes, expandMutes } from 'themes/glitch/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']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class Mutes extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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 name='mutes' 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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..22a10753f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/clear_column_button.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default class ClearColumnButton extends React.Component {
+
+  static propTypes = {
+    onClick: PropTypes.func.isRequired,
+  };
+
+  render () {
+    return (
+      <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.props.onClick}><i className='fa fa-eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/column_settings.js b/app/javascript/themes/glitch/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..88a29d4d3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/column_settings.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ClearColumnButton from './clear_column_button';
+import SettingToggle from './setting_toggle';
+
+export default class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    pushSettings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSave: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+  };
+
+  onPushChange = (key, checked) => {
+    this.props.onChange(['push', ...key], checked);
+  }
+
+  render () {
+    const { settings, pushSettings, onChange, onClear } = 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' />;
+
+    const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
+    const pushStr = showPushSettings && <FormattedMessage id='notifications.column_settings.push' defaultMessage='Push notifications' />;
+    const pushMeta = showPushSettings && <FormattedMessage id='notifications.column_settings.push_meta' defaultMessage='This device' />;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <ClearColumnButton onClick={onClear} />
+        </div>
+
+        <div role='group' aria-labelledby='notifications-follow'>
+          <span id='notifications-follow' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow' defaultMessage='New followers:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-favourite'>
+          <span id='notifications-favourite' className='column-settings__section'><FormattedMessage id='notifications.column_settings.favourite' defaultMessage='Favourites:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'favourite']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-mention'>
+          <span id='notifications-mention' className='column-settings__section'><FormattedMessage id='notifications.column_settings.mention' defaultMessage='Mentions:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'mention']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-reblog'>
+          <span id='notifications-reblog' className='column-settings__section'><FormattedMessage id='notifications.column_settings.reblog' defaultMessage='Boosts:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingKey={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingKey={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingKey={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/follow.js b/app/javascript/themes/glitch/features/notifications/components/follow.js
new file mode 100644
index 000000000..8a0f01736
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/follow.js
@@ -0,0 +1,97 @@
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+
+// Our imports.
+import Permalink from 'themes/glitch/components/permalink';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+
+export default class NotificationFollow extends ImmutablePureComponent {
+
+  static propTypes = {
+    id: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleMoveUp = () => {
+    const { notification, onMoveUp } = this.props;
+    onMoveUp(notification.get('id'));
+  }
+
+  handleMoveDown = () => {
+    const { notification, onMoveDown } = this.props;
+    onMoveDown(notification.get('id'));
+  }
+
+  handleOpen = () => {
+    this.handleOpenProfile();
+  }
+
+  handleOpenProfile = () => {
+    const { notification } = this.props;
+    this.context.router.history.push(`/accounts/${notification.getIn(['account', 'id'])}`);
+  }
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  }
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
+  render () {
+    const { account, notification } = this.props;
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/accounts/${account.get('id')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      />
+    );
+
+    //  Renders.
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <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} />
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/notification.js b/app/javascript/themes/glitch/features/notifications/components/notification.js
new file mode 100644
index 000000000..a309d3a42
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/notification.js
@@ -0,0 +1,88 @@
+//  Package imports.
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Our imports,
+import StatusContainer from 'themes/glitch/containers/status_container';
+import NotificationFollow from './follow';
+
+export default class Notification extends ImmutablePureComponent {
+
+  static propTypes = {
+    notification: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
+    onMoveUp: PropTypes.func.isRequired,
+    onMoveDown: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    settings: ImmutablePropTypes.map.isRequired,
+  };
+
+  renderFollow () {
+    const { notification } = this.props;
+    return (
+      <NotificationFollow
+        id={notification.get('id')}
+        account={notification.get('account')}
+        notification={notification}
+      />
+    );
+  }
+
+  renderMention () {
+    const { notification } = this.props;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        notification={notification}
+        withDismiss
+      />
+    );
+  }
+
+  renderFavourite () {
+    const { notification } = this.props;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        account={notification.get('account')}
+        prepend='favourite'
+        muted
+        notification={notification}
+        withDismiss
+      />
+    );
+  }
+
+  renderReblog () {
+    const { notification } = this.props;
+    return (
+      <StatusContainer
+        id={notification.get('status')}
+        account={notification.get('account')}
+        prepend='reblog'
+        muted
+        notification={notification}
+        withDismiss
+      />
+    );
+  }
+
+  render () {
+    const { notification } = this.props;
+    switch(notification.get('type')) {
+    case 'follow':
+      return this.renderFollow();
+    case 'mention':
+      return this.renderMention();
+    case 'favourite':
+      return this.renderFavourite();
+    case 'reblog':
+      return this.renderReblog();
+    default:
+      return null;
+    }
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/overlay.js b/app/javascript/themes/glitch/features/notifications/components/overlay.js
new file mode 100644
index 000000000..e56f9c628
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/overlay.js
@@ -0,0 +1,57 @@
+/**
+ * Notification overlay
+ */
+
+
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
+});
+
+@injectIntl
+export default class NotificationOverlay extends ImmutablePureComponent {
+
+  static propTypes = {
+    notification    : ImmutablePropTypes.map.isRequired,
+    onMarkForDelete : PropTypes.func.isRequired,
+    show            : PropTypes.bool.isRequired,
+    intl            : PropTypes.object.isRequired,
+  };
+
+  onToggleMark = () => {
+    const mark = !this.props.notification.get('markedForDelete');
+    const id = this.props.notification.get('id');
+    this.props.onMarkForDelete(id, mark);
+  }
+
+  render () {
+    const { notification, show, intl } = this.props;
+
+    const active = notification.get('markedForDelete');
+    const label = intl.formatMessage(messages.markForDeletion);
+
+    return show ? (
+      <div
+        aria-label={label}
+        role='checkbox'
+        aria-checked={active}
+        tabIndex={0}
+        className={`notification__dismiss-overlay ${active ? 'active' : ''}`}
+        onClick={this.onToggleMark}
+      >
+        <div className='wrappy'>
+          <div className='ckbox' aria-hidden='true' title={label}>
+            {active ? (<i className='fa fa-check' />) : ''}
+          </div>
+        </div>
+      </div>
+    ) : null;
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..281359d2a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/components/setting_toggle.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class SettingToggle extends React.PureComponent {
+
+  static propTypes = {
+    prefix: PropTypes.string,
+    settings: ImmutablePropTypes.map.isRequired,
+    settingKey: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    meta: PropTypes.node,
+    onChange: PropTypes.func.isRequired,
+  }
+
+  onChange = ({ target }) => {
+    this.props.onChange(this.props.settingKey, target.checked);
+  }
+
+  render () {
+    const { prefix, settings, settingKey, label, meta } = this.props;
+    const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-');
+
+    return (
+      <div className='setting-toggle'>
+        <Toggle id={id} checked={settings.getIn(settingKey)} onChange={this.onChange} onKeyDown={this.onKeyDown} />
+        <label htmlFor={id} className='setting-toggle__label'>{label}</label>
+        {meta && <span className='setting-meta__label'>{meta}</span>}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..ddc8495f4
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,44 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting, saveSettings } from 'themes/glitch/actions/settings';
+import { clearNotifications } from 'themes/glitch/actions/notifications';
+import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from 'themes/glitch/actions/push_notifications';
+import { openModal } from 'themes/glitch/actions/modal';
+
+const messages = defineMessages({
+  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 mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'notifications']),
+  pushSettings: state.get('push_notifications'),
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange (key, checked) {
+    if (key[0] === 'push') {
+      dispatch(changePushNotifications(key.slice(1), checked));
+    } else {
+      dispatch(changeSetting(['notifications', ...key], checked));
+    }
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+    dispatch(savePushNotificationSettings());
+  },
+
+  onClear () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.clearMessage),
+      confirm: intl.formatMessage(messages.clearConfirm),
+      onConfirm: () => dispatch(clearNotifications()),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ColumnSettings));
diff --git a/app/javascript/themes/glitch/features/notifications/containers/notification_container.js b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..b61aaa21c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/notification_container.js
@@ -0,0 +1,27 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import { makeGetNotification } from 'themes/glitch/selectors';
+import Notification from '../components/notification';
+import { mentionCompose } from 'themes/glitch/actions/compose';
+
+const makeMapStateToProps = () => {
+  const getNotification = makeGetNotification();
+
+  const mapStateToProps = (state, props) => ({
+    notification: getNotification(state, props.notification, props.accountId),
+    settings: state.get('local_settings'),
+    notifCleaning: state.getIn(['notifications', 'cleaningMode']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+  onMention: (account, router) => {
+    dispatch(mentionCompose(account, router));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(Notification);
diff --git a/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js
new file mode 100644
index 000000000..52649cdd7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/containers/overlay_container.js
@@ -0,0 +1,18 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import NotificationOverlay from '../components/overlay';
+import { markNotificationForDelete } from 'themes/glitch/actions/notifications';
+
+const mapDispatchToProps = dispatch => ({
+  onMarkForDelete(id, yes) {
+    dispatch(markNotificationForDelete(id, yes));
+  },
+});
+
+const mapStateToProps = state => ({
+  show: state.getIn(['notifications', 'cleaningMode']),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationOverlay);
diff --git a/app/javascript/themes/glitch/features/notifications/index.js b/app/javascript/themes/glitch/features/notifications/index.js
new file mode 100644
index 000000000..1ecde660a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/notifications/index.js
@@ -0,0 +1,193 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+  enterNotificationClearingMode,
+  expandNotifications,
+  scrollTopNotifications,
+} from 'themes/glitch/actions/notifications';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from 'themes/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+});
+
+const getNotifications = createSelector([
+  state => ImmutableList(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),
+  localSettings:  state.get('local_settings'),
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0,
+  hasMore: !!state.getIn(['notifications', 'next']),
+  notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+});
+
+/* glitch */
+const mapDispatchToProps = dispatch => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+  dispatch,
+});
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class Notifications extends React.PureComponent {
+
+  static propTypes = {
+    columnId: PropTypes.string,
+    notifications: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    isLoading: PropTypes.bool,
+    isUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    localSettings: ImmutablePropTypes.map,
+    notifCleaningActive: PropTypes.bool,
+    onEnterCleaningMode: PropTypes.func,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  handleScrollToBottom = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+    this.props.dispatch(expandNotifications());
+  }, 300, { leading: true });
+
+  handleScrollToTop = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(true));
+  }, 100);
+
+  handleScroll = debounce(() => {
+    this.props.dispatch(scrollTopNotifications(false));
+  }, 100);
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('NOTIFICATIONS', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setColumnRef = c => {
+    this.column = c;
+  }
+
+  handleMoveUp = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
+    this._selectChild(elementIndex);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
+    this._selectChild(elementIndex);
+  }
+
+  _selectChild (index) {
+    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  render () {
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
+    const pinned = !!columnId;
+    const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
+
+    let scrollableContent = null;
+
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
+    } else if (notifications.size > 0 || hasMore) {
+      scrollableContent = notifications.map((item) => (
+        <NotificationContainer
+          key={item.get('id')}
+          notification={item}
+          accountId={item.get('account')}
+          onMoveUp={this.handleMoveUp}
+          onMoveDown={this.handleMoveDown}
+        />
+      ));
+    } else {
+      scrollableContent = null;
+    }
+
+    this.scrollableContent = scrollableContent;
+
+    const scrollContainer = (
+      <ScrollableList
+        scrollKey={`notifications-${columnId}`}
+        trackScroll={!pinned}
+        isLoading={isLoading}
+        hasMore={hasMore}
+        emptyMessage={emptyMessage}
+        onScrollToBottom={this.handleScrollToBottom}
+        onScrollToTop={this.handleScrollToTop}
+        onScroll={this.handleScroll}
+        shouldUpdateScroll={shouldUpdateScroll}
+      >
+        {scrollableContent}
+      </ScrollableList>
+    );
+
+    return (
+      <Column
+        ref={this.setColumnRef}
+        name='notifications'
+        extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
+      >
+        <ColumnHeader
+          icon='bell'
+          active={isUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          localSettings={this.props.localSettings}
+          notifCleaning
+          notifCleaningActive={this.props.notifCleaningActive} // this is used to toggle the header text
+          onEnterCleaningMode={this.props.onEnterCleaningMode}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        {scrollContainer}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/pinned_statuses/index.js b/app/javascript/themes/glitch/features/pinned_statuses/index.js
new file mode 100644
index 000000000..0a3997850
--- /dev/null
+++ b/app/javascript/themes/glitch/features/pinned_statuses/index.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from 'themes/glitch/actions/pin_statuses';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import StatusList from 'themes/glitch/components/status_list';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.pins', defaultMessage: 'Pinned toot' },
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'pins', 'items']),
+  hasMore: !!state.getIn(['status_lists', 'pins', 'next']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PinnedStatuses extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchPinnedStatuses());
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, statusIds, hasMore } = this.props;
+
+    return (
+      <Column icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
+        <ColumnBackButtonSlim />
+        <StatusList
+          statusIds={statusIds}
+          scrollKey='pinned_statuses'
+          hasMore={hasMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..0185a7724
--- /dev/null
+++ b/app/javascript/themes/glitch/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from 'themes/glitch/features/community_timeline/components/column_settings';
+import { changeSetting } from 'themes/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'public']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (key, checked) {
+    dispatch(changeSetting(['public', ...key], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/themes/glitch/features/public_timeline/index.js b/app/javascript/themes/glitch/features/public_timeline/index.js
new file mode 100644
index 000000000..f5b3865af
--- /dev/null
+++ b/app/javascript/themes/glitch/features/public_timeline/index.js
@@ -0,0 +1,107 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import {
+  refreshPublicTimeline,
+  expandPublicTimeline,
+} from 'themes/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'themes/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from 'themes/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'public', 'unread']) > 0,
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasUnread: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('PUBLIC', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshPublicTimeline());
+    this.disconnect = dispatch(connectPublicStream());
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandPublicTimeline());
+  }
+
+  render () {
+    const { intl, columnId, hasUnread, multiColumn } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='federated'>
+        <ColumnHeader
+          icon='globe'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          timelineId='public'
+          loadMore={this.handleLoadMore}
+          trackScroll={!pinned}
+          scrollKey={`public_timeline-${columnId}`}
+          emptyMessage={<FormattedMessage id='empty_column.public' defaultMessage='There is nothing here! Write something publicly, or manually follow users from other instances to fill it up' />}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/reblogs/index.js b/app/javascript/themes/glitch/features/reblogs/index.js
new file mode 100644
index 000000000..8723f7c7c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/reblogs/index.js
@@ -0,0 +1,60 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+import { fetchReblogs } from 'themes/glitch/actions/interactions';
+import { ScrollContainer } from 'react-router-scroll-4';
+import AccountContainer from 'themes/glitch/containers/account_container';
+import Column from 'themes/glitch/features/ui/components/column';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+@connect(mapStateToProps)
+export default class Reblogs extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this.props.dispatch(fetchReblogs(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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/report/components/status_check_box.js b/app/javascript/themes/glitch/features/report/components/status_check_box.js
new file mode 100644
index 000000000..cc9232201
--- /dev/null
+++ b/app/javascript/themes/glitch/features/report/components/status_check_box.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+
+export default class StatusCheckBox extends React.PureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    checked: PropTypes.bool,
+    onToggle: PropTypes.func.isRequired,
+    disabled: PropTypes.bool,
+  };
+
+  render () {
+    const { status, checked, onToggle, disabled } = this.props;
+    const content = { __html: status.get('contentHtml') };
+
+    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>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js b/app/javascript/themes/glitch/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..40d55fb3c
--- /dev/null
+++ b/app/javascript/themes/glitch/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 'themes/glitch/actions/reports';
+import { Set as ImmutableSet } from 'immutable';
+
+const mapStateToProps = (state, { id }) => ({
+  status: state.getIn(['statuses', id]),
+  checked: state.getIn(['reports', 'new', 'status_ids'], ImmutableSet()).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/themes/glitch/features/standalone/compose/index.js b/app/javascript/themes/glitch/features/standalone/compose/index.js
new file mode 100644
index 000000000..8a8118178
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from 'themes/glitch/features/compose/containers/compose_form_container';
+import NotificationsContainer from 'themes/glitch/features/ui/containers/notifications_container';
+import LoadingBarContainer from 'themes/glitch/features/ui/containers/loading_bar_container';
+import ModalContainer from 'themes/glitch/features/ui/containers/modal_container';
+
+export default class Compose extends React.PureComponent {
+
+  render () {
+    return (
+      <div>
+        <ComposeFormContainer />
+        <NotificationsContainer />
+        <ModalContainer />
+        <LoadingBarContainer className='loading-bar' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..7c56f264f
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import {
+  refreshHashtagTimeline,
+  expandHashtagTimeline,
+} from 'themes/glitch/actions/timelines';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+
+@connect()
+export default class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    hashtag: PropTypes.string.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch, hashtag } = this.props;
+
+    dispatch(refreshHashtagTimeline(hashtag));
+
+    this.polling = setInterval(() => {
+      dispatch(refreshHashtagTimeline(hashtag));
+    }, 10000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandHashtagTimeline(this.props.hashtag));
+  }
+
+  render () {
+    const { hashtag } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='hashtag'
+          title={hashtag}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          trackScroll={false}
+          scrollKey='standalone_hashtag_timeline'
+          timelineId={`hashtag:${hashtag}`}
+          loadMore={this.handleLoadMore}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/standalone/public_timeline/index.js b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js
new file mode 100644
index 000000000..b3fb55288
--- /dev/null
+++ b/app/javascript/themes/glitch/features/standalone/public_timeline/index.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'themes/glitch/features/ui/containers/status_list_container';
+import {
+  refreshPublicTimeline,
+  expandPublicTimeline,
+} from 'themes/glitch/actions/timelines';
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
+});
+
+@connect()
+@injectIntl
+export default class PublicTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(refreshPublicTimeline());
+
+    this.polling = setInterval(() => {
+      dispatch(refreshPublicTimeline());
+    }, 3000);
+  }
+
+  componentWillUnmount () {
+    if (typeof this.polling !== 'undefined') {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleLoadMore = () => {
+    this.props.dispatch(expandPublicTimeline());
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='globe'
+          title={intl.formatMessage(messages.title)}
+          onClick={this.handleHeaderClick}
+        />
+
+        <StatusListContainer
+          timelineId='public'
+          loadMore={this.handleLoadMore}
+          scrollKey='standalone_public_timeline'
+          trackScroll={false}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/action_bar.js b/app/javascript/themes/glitch/features/status/components/action_bar.js
new file mode 100644
index 000000000..6cda988d1
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/action_bar.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'themes/glitch/components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from 'themes/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me } from 'themes/glitch/util/initial_state';
+
+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}' },
+  share: { id: 'status.share', defaultMessage: 'Share' },
+  pin: { id: 'status.pin', defaultMessage: 'Pin on profile' },
+  unpin: { id: 'status.unpin', defaultMessage: 'Unpin from profile' },
+  embed: { id: 'status.embed', defaultMessage: 'Embed' },
+});
+
+@injectIntl
+export default class ActionBar extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static 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,
+    onPin: PropTypes.func,
+    onEmbed: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  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.history);
+  }
+
+  handleReport = () => {
+    this.props.onReport(this.props.status);
+  }
+
+  handlePinClick = () => {
+    this.props.onPin(this.props.status);
+  }
+
+  handleShare = () => {
+    navigator.share({
+      text: this.props.status.get('search_index'),
+      url: this.props.status.get('url'),
+    });
+  }
+
+  handleEmbed = () => {
+    this.props.onEmbed(this.props.status);
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+
+    let menu = [];
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+    }
+
+    if (me === status.getIn(['account', 'id'])) {
+      if (publicStatus) {
+        menu.push({ text: intl.formatMessage(status.get('pinned') ? messages.unpin : messages.pin), action: this.handlePinClick });
+      }
+
+      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 });
+    }
+
+    const shareButton = ('share' in navigator) && status.get('visibility') === 'public' && (
+      <div className='detailed-status__button'><IconButton title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShare} /></div>
+    );
+
+    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 active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
+        {shareButton}
+
+        <div className='detailed-status__action-bar-dropdown'>
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' ariaLabel='More' />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/card.js b/app/javascript/themes/glitch/features/status/components/card.js
new file mode 100644
index 000000000..bb83374b9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/card.js
@@ -0,0 +1,125 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import punycode from 'punycode';
+import classnames from 'classnames';
+
+const IDNA_PREFIX = 'xn--';
+
+const decodeIDNA = domain => {
+  return domain
+    .split('.')
+    .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
+    .join('.');
+};
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+export default class Card extends React.PureComponent {
+
+  static propTypes = {
+    card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
+  };
+
+  state = {
+    width: 0,
+  };
+
+  renderLink () {
+    const { card, maxDescription } = 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' width={card.get('width')} height={card.get('height')} />
+        </div>
+      );
+    }
+
+    if (provider.length < 1) {
+      provider = decodeIDNA(getHostname(card.get('url')));
+    }
+
+    const className = classnames('status-card', {
+      'horizontal': card.get('width') > card.get('height'),
+    });
+
+    return (
+      <a href={card.get('url')} className={className} 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, maxDescription)}</p>
+          <span className='status-card__host'>{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>
+    );
+  }
+
+  setRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
+  renderVideo () {
+    const { card }  = this.props;
+    const content   = { __html: card.get('html') };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = card.get('width') > card.get('height') ? (width / ratio) : (width * ratio);
+
+    return (
+      <div
+        ref={this.setRef}
+        className='status-card-video'
+        dangerouslySetInnerHTML={content}
+        style={{ height }}
+      />
+    );
+  }
+
+  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;
+    }
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/components/detailed_status.js b/app/javascript/themes/glitch/features/status/components/detailed_status.js
new file mode 100644
index 000000000..7606bfbf3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/components/detailed_status.js
@@ -0,0 +1,128 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'themes/glitch/components/avatar';
+import DisplayName from 'themes/glitch/components/display_name';
+import StatusContent from 'themes/glitch/components/status_content';
+import StatusGallery from 'themes/glitch/components/media_gallery';
+import AttachmentList from 'themes/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { FormattedDate, FormattedNumber } from 'react-intl';
+import CardContainer from '../containers/card_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'themes/glitch/features/video';
+import VisibilityIcon from 'themes/glitch/components/status_visibility_icon';
+
+export default class DetailedStatus extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    settings: ImmutablePropTypes.map.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+    onOpenVideo: PropTypes.func.isRequired,
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+
+    e.stopPropagation();
+  }
+
+  // handleOpenVideo = startTime => {
+  //   this.props.onOpenVideo(this.props.status.getIn(['media_attachments', 0]), startTime);
+  // }
+
+  render () {
+    const status = this.props.status.get('reblog') ? this.props.status.get('reblog') : this.props.status;
+    const { settings } = this.props;
+
+    let media           = '';
+    let mediaIcon       = null;
+    let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
+
+    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 = (
+          <Video
+            sensitive={status.get('sensitive')}
+            media={status.getIn(['media_attachments', 0])}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            onOpenVideo={this.props.onOpenVideo}
+            autoplay
+          />
+        );
+        mediaIcon = 'video-camera';
+      } else {
+        media = (
+          <StatusGallery
+            sensitive={status.get('sensitive')}
+            media={status.get('media_attachments')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            onOpenMedia={this.props.onOpenMedia}
+          />
+        );
+        mediaIcon = 'picture-o';
+      }
+    } else 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>;
+    }
+
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (status.get('visibility') === 'private') {
+      reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else {
+      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+        <i className={`fa fa-${reblogIcon}`} />
+        <span className='detailed-status__reblogs'>
+          <FormattedNumber value={status.get('reblogs_count')} />
+        </span>
+      </Link>);
+    }
+
+    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 account={status.get('account')} size={48} /></div>
+          <DisplayName account={status.get('account')} />
+        </a>
+
+        <StatusContent
+          status={status}
+          media={media}
+          mediaIcon={mediaIcon}
+        />
+
+        <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} · {reblogLink} · <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> · <VisibilityIcon visibility={status.get('visibility')} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/status/containers/card_container.js b/app/javascript/themes/glitch/features/status/containers/card_container.js
new file mode 100644
index 000000000..a97404de1
--- /dev/null
+++ b/app/javascript/themes/glitch/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/themes/glitch/features/status/index.js b/app/javascript/themes/glitch/features/status/index.js
new file mode 100644
index 000000000..57af94a9a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/status/index.js
@@ -0,0 +1,343 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchStatus } from 'themes/glitch/actions/statuses';
+import MissingIndicator from 'themes/glitch/components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from 'themes/glitch/features/ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  reblog,
+  unreblog,
+  pin,
+  unpin,
+} from 'themes/glitch/actions/interactions';
+import {
+  replyCompose,
+  mentionCompose,
+} from 'themes/glitch/actions/compose';
+import { deleteStatus } from 'themes/glitch/actions/statuses';
+import { initReport } from 'themes/glitch/actions/reports';
+import { makeGetStatus } from 'themes/glitch/selectors';
+import { ScrollContainer } from 'react-router-scroll-4';
+import ColumnBackButton from 'themes/glitch/components/column_back_button';
+import StatusContainer from 'themes/glitch/containers/status_container';
+import { openModal } from 'themes/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, deleteModal } from 'themes/glitch/util/initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'themes/glitch/util/fullscreen';
+
+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, props.params.statusId),
+    settings: state.get('local_settings'),
+    ancestorsIds: state.getIn(['contexts', 'ancestors', props.params.statusId]),
+    descendantsIds: state.getIn(['contexts', 'descendants', props.params.statusId]),
+  });
+
+  return mapStateToProps;
+};
+
+@injectIntl
+@connect(makeMapStateToProps)
+export default class Status extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    status: ImmutablePropTypes.map,
+    settings: ImmutablePropTypes.map.isRequired,
+    ancestorsIds: ImmutablePropTypes.list,
+    descendantsIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    fullscreen: false,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
+  }
+
+  componentDidMount () {
+    attachFullscreenListener(this.onFullScreenChange);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.params.statusId !== this.props.params.statusId && nextProps.params.statusId) {
+      this._scrolledIntoView = false;
+      this.props.dispatch(fetchStatus(nextProps.params.statusId));
+    }
+  }
+
+  handleFavouriteClick = (status) => {
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      this.props.dispatch(favourite(status));
+    }
+  }
+
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  }
+
+  handleReplyClick = (status) => {
+    this.props.dispatch(replyCompose(status, this.context.router.history));
+  }
+
+  handleModalReblog = (status) => {
+    this.props.dispatch(reblog(status));
+  }
+
+  handleReblogClick = (status, e) => {
+    if (status.get('reblogged')) {
+      this.props.dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !boostModal) {
+        this.handleModalReblog(status);
+      } else {
+        this.props.dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+      }
+    }
+  }
+
+  handleDeleteClick = (status) => {
+    const { dispatch, intl } = this.props;
+
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id')));
+    } else {
+      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));
+  }
+
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.handleMoveUp(this.props.status.get('id'));
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.handleMoveDown(this.props.status.get('id'));
+  }
+
+  handleHotkeyReply = e => {
+    e.preventDefault();
+    this.handleReplyClick(this.props.status);
+  }
+
+  handleHotkeyFavourite = () => {
+    this.handleFavouriteClick(this.props.status);
+  }
+
+  handleHotkeyBoost = () => {
+    this.handleReblogClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index);
+      } else {
+        this._selectChild(index - 1);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2);
+      } else {
+        this._selectChild(index + 1);
+      }
+    }
+  }
+
+  _selectChild (index) {
+    const element = this.node.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  renderChildren (list) {
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+      />
+    ));
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidUpdate () {
+    if (this._scrolledIntoView) {
+      return;
+    }
+
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+      if (element) {
+        element.scrollIntoView(true);
+        this._scrolledIntoView = true;
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  render () {
+    let ancestors, descendants;
+    const { status, settings, ancestorsIds, descendantsIds } = this.props;
+    const { fullscreen } = this.state;
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (ancestorsIds && ancestorsIds.size > 0) {
+      ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
+    }
+
+    if (descendantsIds && descendantsIds.size > 0) {
+      descendants = <div>{this.renderChildren(descendantsIds)}</div>;
+    }
+
+    const handlers = {
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      reply: this.handleHotkeyReply,
+      favourite: this.handleHotkeyFavourite,
+      boost: this.handleHotkeyBoost,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+    };
+
+    return (
+      <Column>
+        <ColumnBackButton />
+
+        <ScrollContainer scrollKey='thread'>
+          <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
+            {ancestors}
+
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0'>
+                <DetailedStatus
+                  status={status}
+                  settings={settings}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                />
+
+                <ActionBar
+                  status={status}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onDelete={this.handleDeleteClick}
+                  onMention={this.handleMentionClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
+
+            {descendants}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/actions_modal.js b/app/javascript/themes/glitch/features/ui/components/actions_modal.js
new file mode 100644
index 000000000..7a2b78b63
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/actions_modal.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import StatusContent from 'themes/glitch/components/status_content';
+import Avatar from 'themes/glitch/components/avatar';
+import RelativeTimestamp from 'themes/glitch/components/relative_timestamp';
+import DisplayName from 'themes/glitch/components/display_name';
+import IconButton from 'themes/glitch/components/icon_button';
+import classNames from 'classnames';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    actions: PropTypes.array,
+    onClick: PropTypes.func,
+  };
+
+  renderAction = (action, i) => {
+    if (action === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const { icon = null, text, meta = null, active = false, href = '#' } = action;
+
+    return (
+      <li key={`${text}-${i}`}>
+        <a href={href} target='_blank' rel='noopener' onClick={this.props.onClick} data-index={i} className={classNames({ active })}>
+          {icon && <IconButton title={text} icon={icon} role='presentation' tabIndex='-1' />}
+          <div>
+            <div className={classNames({ 'actions-modal__item-label': !!meta })}>{text}</div>
+            <div>{meta}</div>
+          </div>
+        </a>
+      </li>
+    );
+  }
+
+  render () {
+    const status = this.props.status && (
+      <div className='status light'>
+        <div className='boost-modal__status-header'>
+          <div className='boost-modal__status-time'>
+            <a href={this.props.status.get('url')} className='status__relative-time' target='_blank' rel='noopener'>
+              <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+            </a>
+          </div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name'>
+            <div className='status__avatar'>
+              <Avatar account={this.props.status.get('account')} size={48} />
+            </div>
+
+            <DisplayName account={this.props.status.get('account')} />
+          </a>
+        </div>
+
+        <StatusContent status={this.props.status} />
+      </div>
+    );
+
+    return (
+      <div className='modal-root__modal actions-modal'>
+        {status}
+
+        <ul>
+          {this.props.actions.map(this.renderAction)}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/boost_modal.js b/app/javascript/themes/glitch/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..49781db10
--- /dev/null
+++ b/app/javascript/themes/glitch/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 Button from 'themes/glitch/components/button';
+import StatusContent from 'themes/glitch/components/status_content';
+import Avatar from 'themes/glitch/components/avatar';
+import RelativeTimestamp from 'themes/glitch/components/relative_timestamp';
+import DisplayName from 'themes/glitch/components/display_name';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+@injectIntl
+export default class BoostModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleReblog = () => {
+    this.props.onReblog(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
+    }
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { status, intl } = 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 account={status.get('account')} 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} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle.js b/app/javascript/themes/glitch/features/ui/components/bundle.js
new file mode 100644
index 000000000..fc88e0c70
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const emptyComponent = () => null;
+const noop = () => { };
+
+class Bundle extends React.Component {
+
+  static propTypes = {
+    fetchComponent: PropTypes.func.isRequired,
+    loading: PropTypes.func,
+    error: PropTypes.func,
+    children: PropTypes.func.isRequired,
+    renderDelay: PropTypes.number,
+    onFetch: PropTypes.func,
+    onFetchSuccess: PropTypes.func,
+    onFetchFail: PropTypes.func,
+  }
+
+  static defaultProps = {
+    loading: emptyComponent,
+    error: emptyComponent,
+    renderDelay: 0,
+    onFetch: noop,
+    onFetchSuccess: noop,
+    onFetchFail: noop,
+  }
+
+  static cache = {}
+
+  state = {
+    mod: undefined,
+    forceRender: false,
+  }
+
+  componentWillMount() {
+    this.load(this.props);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.fetchComponent !== this.props.fetchComponent) {
+      this.load(nextProps);
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.timeout) {
+      clearTimeout(this.timeout);
+    }
+  }
+
+  load = (props) => {
+    const { fetchComponent, onFetch, onFetchSuccess, onFetchFail, renderDelay } = props || this.props;
+
+    onFetch();
+
+    if (Bundle.cache[fetchComponent.name]) {
+      const mod = Bundle.cache[fetchComponent.name];
+
+      this.setState({ mod: mod.default });
+      onFetchSuccess();
+      return Promise.resolve();
+    }
+
+    this.setState({ mod: undefined });
+
+    if (renderDelay !== 0) {
+      this.timestamp = new Date();
+      this.timeout = setTimeout(() => this.setState({ forceRender: true }), renderDelay);
+    }
+
+    return fetchComponent()
+      .then((mod) => {
+        Bundle.cache[fetchComponent.name] = mod;
+        this.setState({ mod: mod.default });
+        onFetchSuccess();
+      })
+      .catch((error) => {
+        this.setState({ mod: null });
+        onFetchFail(error);
+      });
+  }
+
+  render() {
+    const { loading: Loading, error: Error, children, renderDelay } = this.props;
+    const { mod, forceRender } = this.state;
+    const elapsed = this.timestamp ? (new Date() - this.timestamp) : renderDelay;
+
+    if (mod === undefined) {
+      return (elapsed >= renderDelay || forceRender) ? <Loading /> : null;
+    }
+
+    if (mod === null) {
+      return <Error onRetry={this.load} />;
+    }
+
+    return children(mod);
+  }
+
+}
+
+export default Bundle;
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js
new file mode 100644
index 000000000..daedc6299
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle_column_error.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import Column from './column';
+import ColumnHeader from './column_header';
+import ColumnBackButtonSlim from 'themes/glitch/components/column_back_button_slim';
+import IconButton from 'themes/glitch/components/icon_button';
+
+const messages = defineMessages({
+  title: { id: 'bundle_column_error.title', defaultMessage: 'Network error' },
+  body: { id: 'bundle_column_error.body', defaultMessage: 'Something went wrong while loading this component.' },
+  retry: { id: 'bundle_column_error.retry', defaultMessage: 'Try again' },
+});
+
+class BundleColumnError extends React.Component {
+
+  static propTypes = {
+    onRetry: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  handleRetry = () => {
+    this.props.onRetry();
+  }
+
+  render () {
+    const { intl: { formatMessage } } = this.props;
+
+    return (
+      <Column>
+        <ColumnHeader icon='exclamation-circle' type={formatMessage(messages.title)} />
+        <ColumnBackButtonSlim />
+        <div className='error-column'>
+          <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
+          {formatMessage(messages.body)}
+        </div>
+      </Column>
+    );
+  }
+
+}
+
+export default injectIntl(BundleColumnError);
diff --git a/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js
new file mode 100644
index 000000000..8cca32ae9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/bundle_modal_error.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+
+import IconButton from 'themes/glitch/components/icon_button';
+
+const messages = defineMessages({
+  error: { id: 'bundle_modal_error.message', defaultMessage: 'Something went wrong while loading this component.' },
+  retry: { id: 'bundle_modal_error.retry', defaultMessage: 'Try again' },
+  close: { id: 'bundle_modal_error.close', defaultMessage: 'Close' },
+});
+
+class BundleModalError extends React.Component {
+
+  static propTypes = {
+    onRetry: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  handleRetry = () => {
+    this.props.onRetry();
+  }
+
+  render () {
+    const { onClose, intl: { formatMessage } } = this.props;
+
+    // Keep the markup in sync with <ModalLoading />
+    // (make sure they have the same dimensions)
+    return (
+      <div className='modal-root__modal error-modal'>
+        <div className='error-modal__body'>
+          <IconButton title={formatMessage(messages.retry)} icon='refresh' onClick={this.handleRetry} size={64} />
+          {formatMessage(messages.error)}
+        </div>
+
+        <div className='error-modal__footer'>
+          <div>
+            <button
+              onClick={onClose}
+              className='error-modal__nav onboarding-modal__skip'
+            >
+              {formatMessage(messages.close)}
+            </button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
+
+export default injectIntl(BundleModalError);
diff --git a/app/javascript/themes/glitch/features/ui/components/column.js b/app/javascript/themes/glitch/features/ui/components/column.js
new file mode 100644
index 000000000..73a5bc15e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column.js
@@ -0,0 +1,74 @@
+import React from 'react';
+import ColumnHeader from './column_header';
+import PropTypes from 'prop-types';
+import { debounce } from 'lodash';
+import { scrollTop } from 'themes/glitch/util/scroll';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+
+export default class Column extends React.PureComponent {
+
+  static propTypes = {
+    heading: PropTypes.string,
+    icon: PropTypes.string,
+    children: PropTypes.node,
+    active: PropTypes.bool,
+    hideHeadingOnMobile: PropTypes.bool,
+    name: PropTypes.string,
+  };
+
+  handleHeaderClick = () => {
+    const scrollable = this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+  scrollTop () {
+    const scrollable = this.node.querySelector('.scrollable');
+
+    if (!scrollable) {
+      return;
+    }
+
+    this._interruptScrollAnimation = scrollTop(scrollable);
+  }
+
+
+  handleScroll = debounce(() => {
+    if (typeof this._interruptScrollAnimation !== 'undefined') {
+      this._interruptScrollAnimation();
+    }
+  }, 200)
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const { heading, icon, children, active, hideHeadingOnMobile, name } = this.props;
+
+    const showHeading = heading && (!hideHeadingOnMobile || (hideHeadingOnMobile && !isMobile(window.innerWidth)));
+
+    const columnHeaderId = showHeading && heading.replace(/ /g, '-');
+    const header = showHeading && (
+      <ColumnHeader icon={icon} active={active} type={heading} onClick={this.handleHeaderClick} columnHeaderId={columnHeaderId} />
+    );
+    return (
+      <div
+        ref={this.setRef}
+        role='region'
+        data-column={name}
+        aria-labelledby={columnHeaderId}
+        className='column'
+        onScroll={this.handleScroll}
+      >
+        {header}
+        {children}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_header.js b/app/javascript/themes/glitch/features/ui/components/column_header.js
new file mode 100644
index 000000000..af195ea9c
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_header.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class ColumnHeader extends React.PureComponent {
+
+  static propTypes = {
+    icon: PropTypes.string,
+    type: PropTypes.string,
+    active: PropTypes.bool,
+    onClick: PropTypes.func,
+    columnHeaderId: PropTypes.string,
+  };
+
+  handleClick = () => {
+    this.props.onClick();
+  }
+
+  render () {
+    const { type, active, 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='heading' tabIndex='0' className={`column-header ${active ? 'active' : ''}`} onClick={this.handleClick} id={columnHeaderId || null}>
+        {icon}
+        {type}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_link.js b/app/javascript/themes/glitch/features/ui/components/column_link.js
new file mode 100644
index 000000000..b845d1895
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_link.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+
+const ColumnLink = ({ icon, text, to, onClick, href, method }) => {
+  if (href) {
+    return (
+      <a href={href} className='column-link' data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  } else if (to) {
+    return (
+      <Link to={to} className='column-link'>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </Link>
+    );
+  } else {
+    return (
+      <a onClick={onClick} className='column-link' role='button' tabIndex='0' data-method={method}>
+        <i className={`fa fa-fw fa-${icon} column-link__icon`} />
+        {text}
+      </a>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  method: PropTypes.string,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/themes/glitch/features/ui/components/column_loading.js b/app/javascript/themes/glitch/features/ui/components/column_loading.js
new file mode 100644
index 000000000..75f26218a
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/column_loading.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Column from 'themes/glitch/components/column';
+import ColumnHeader from 'themes/glitch/components/column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class ColumnLoading extends ImmutablePureComponent {
+
+  static propTypes = {
+    title: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
+    icon: PropTypes.string,
+  };
+
+  static defaultProps = {
+    title: '',
+    icon: '',
+  };
+
+  render() {
+    let { title, icon } = this.props;
+    return (
+      <Column>
+        <ColumnHeader icon={icon} title={title} multiColumn={false} focusable={false} />
+        <div className='scrollable' />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/column_subheading.js b/app/javascript/themes/glitch/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/themes/glitch/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/themes/glitch/features/ui/components/columns_area.js b/app/javascript/themes/glitch/features/ui/components/columns_area.js
new file mode 100644
index 000000000..452950363
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/columns_area.js
@@ -0,0 +1,174 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import ReactSwipeableViews from 'react-swipeable-views';
+import { links, getIndex, getLink } from './tabs_bar';
+
+import BundleContainer from '../containers/bundle_container';
+import ColumnLoading from './column_loading';
+import DrawerLoading from './drawer_loading';
+import BundleColumnError from './bundle_column_error';
+import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from 'themes/glitch/util/async-components';
+
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from 'themes/glitch/util/scroll';
+
+const componentMap = {
+  'COMPOSE': Compose,
+  'HOME': HomeTimeline,
+  'NOTIFICATIONS': Notifications,
+  'PUBLIC': PublicTimeline,
+  'COMMUNITY': CommunityTimeline,
+  'HASHTAG': HashtagTimeline,
+  'DIRECT': DirectTimeline,
+  'FAVOURITES': FavouritedStatuses,
+};
+
+@component => injectIntl(component, { withRef: true })
+export default class ColumnsArea extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    columns: ImmutablePropTypes.list.isRequired,
+    singleColumn: PropTypes.bool,
+    children: PropTypes.node,
+  };
+
+  state = {
+    shouldAnimate: false,
+  }
+
+  componentWillReceiveProps() {
+    this.setState({ shouldAnimate: false });
+  }
+
+  componentDidMount() {
+    if (!this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
+    this.lastIndex = getIndex(this.context.router.history.location.pathname);
+    this.setState({ shouldAnimate: true });
+  }
+
+  componentWillUpdate(nextProps) {
+    if (this.props.singleColumn !== nextProps.singleColumn && nextProps.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
+      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+    }
+    this.lastIndex = getIndex(this.context.router.history.location.pathname);
+    this.setState({ shouldAnimate: true });
+  }
+
+  componentWillUnmount () {
+    if (!this.props.singleColumn) {
+      this.node.removeEventListener('wheel', this.handleWheel);
+    }
+  }
+
+  handleChildrenContentChange() {
+    if (!this.props.singleColumn) {
+      this._interruptScrollAnimation = scrollRight(this.node, this.node.scrollWidth - window.innerWidth);
+    }
+  }
+
+  handleSwipe = (index) => {
+    this.pendingIndex = index;
+
+    const nextLinkTranslationId = links[index].props['data-preview-title-id'];
+    const currentLinkSelector = '.tabs-bar__link.active';
+    const nextLinkSelector = `.tabs-bar__link[data-preview-title-id="${nextLinkTranslationId}"]`;
+
+    // HACK: Remove the active class from the current link and set it to the next one
+    // React-router does this for us, but too late, feeling laggy.
+    document.querySelector(currentLinkSelector).classList.remove('active');
+    document.querySelector(nextLinkSelector).classList.add('active');
+  }
+
+  handleAnimationEnd = () => {
+    if (typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
+  }
+
+  handleWheel = () => {
+    if (typeof this._interruptScrollAnimation !== 'function') {
+      return;
+    }
+
+    this._interruptScrollAnimation();
+  }
+
+  setRef = (node) => {
+    this.node = node;
+  }
+
+  renderView = (link, index) => {
+    const columnIndex = getIndex(this.context.router.history.location.pathname);
+    const title = this.props.intl.formatMessage({ id: link.props['data-preview-title-id'] });
+    const icon = link.props['data-preview-icon'];
+
+    const view = (index === columnIndex) ?
+      React.cloneElement(this.props.children) :
+      <ColumnLoading title={title} icon={icon} />;
+
+    return (
+      <div className='columns-area' key={index}>
+        {view}
+      </div>
+    );
+  }
+
+  renderLoading = columnId => () => {
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
+  }
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} />;
+  }
+
+  render () {
+    const { columns, children, singleColumn } = this.props;
+    const { shouldAnimate } = this.state;
+
+    const columnIndex = getIndex(this.context.router.history.location.pathname);
+    this.pendingIndex = null;
+
+    if (singleColumn) {
+      return columnIndex !== -1 ? (
+        <ReactSwipeableViews index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }}>
+          {links.map(this.renderView)}
+        </ReactSwipeableViews>
+      ) : <div className='columns-area'>{children}</div>;
+    }
+
+    return (
+      <div className='columns-area' ref={this.setRef}>
+        {columns.map(column => {
+          const params = column.get('params', null) === null ? null : column.get('params').toJS();
+
+          return (
+            <BundleContainer key={column.get('uuid')} fetchComponent={componentMap[column.get('id')]} loading={this.renderLoading(column.get('id'))} error={this.renderError}>
+              {SpecificComponent => <SpecificComponent columnId={column.get('uuid')} params={params} multiColumn />}
+            </BundleContainer>
+          );
+        })}
+
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..3d568aec3
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/confirmation_modal.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'themes/glitch/components/button';
+
+@injectIntl
+export default class ConfirmationModal extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+    confirm: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm();
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { message, confirm } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div className='confirmation-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/doodle_modal.js b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js
new file mode 100644
index 000000000..819656dbf
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/doodle_modal.js
@@ -0,0 +1,614 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'themes/glitch/components/button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Atrament from 'atrament'; // the doodling library
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { doodleSet, uploadCompose } from 'themes/glitch/actions/compose';
+import IconButton from 'themes/glitch/components/icon_button';
+import { debounce, mapValues } from 'lodash';
+import classNames from 'classnames';
+
+// palette nicked from MyPaint, CC0
+const palette = [
+  ['rgb(  0,    0,    0)', 'Black'],
+  ['rgb( 38,   38,   38)', 'Gray 15'],
+  ['rgb( 77,   77,   77)', 'Grey 30'],
+  ['rgb(128,  128,  128)', 'Grey 50'],
+  ['rgb(171,  171,  171)', 'Grey 67'],
+  ['rgb(217,  217,  217)', 'Grey 85'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(128,    0,    0)', 'Maroon'],
+  ['rgb(209,    0,    0)', 'English-red'],
+  ['rgb(255,   54,   34)', 'Tomato'],
+  ['rgb(252,   60,    3)', 'Orange-red'],
+  ['rgb(255,  140,  105)', 'Salmon'],
+  ['rgb(252,  232,   32)', 'Cadium-yellow'],
+  ['rgb(243,  253,   37)', 'Lemon yellow'],
+  ['rgb(121,    5,   35)', 'Dark crimson'],
+  ['rgb(169,   32,   62)', 'Deep carmine'],
+  ['rgb(255,  140,    0)', 'Orange'],
+  ['rgb(255,  168,   18)', 'Dark tangerine'],
+  ['rgb(217,  144,   88)', 'Persian orange'],
+  ['rgb(194,  178,  128)', 'Sand'],
+  ['rgb(255,  229,  180)', 'Peach'],
+  ['rgb(100,   54,   46)', 'Bole'],
+  ['rgb(108,   41,   52)', 'Dark cordovan'],
+  ['rgb(163,   65,   44)', 'Chestnut'],
+  ['rgb(228,  136,  100)', 'Dark salmon'],
+  ['rgb(255,  195,  143)', 'Apricot'],
+  ['rgb(255,  219,  188)', 'Unbleached silk'],
+  ['rgb(242,  227,  198)', 'Straw'],
+  ['rgb( 53,   19,   13)', 'Bistre'],
+  ['rgb( 84,   42,   14)', 'Dark chocolate'],
+  ['rgb(102,   51,   43)', 'Burnt sienna'],
+  ['rgb(184,   66,    0)', 'Sienna'],
+  ['rgb(216,  153,   12)', 'Yellow ochre'],
+  ['rgb(210,  180,  140)', 'Tan'],
+  ['rgb(232,  204,  144)', 'Dark wheat'],
+  ['rgb(  0,   49,   83)', 'Prussian blue'],
+  ['rgb( 48,   69,  119)', 'Dark grey blue'],
+  ['rgb(  0,   71,  171)', 'Cobalt blue'],
+  ['rgb( 31,  117,  254)', 'Blue'],
+  ['rgb(120,  180,  255)', 'Bright french blue'],
+  ['rgb(171,  200,  255)', 'Bright steel blue'],
+  ['rgb(208,  231,  255)', 'Ice blue'],
+  ['rgb( 30,   51,   58)', 'Medium jungle green'],
+  ['rgb( 47,   79,   79)', 'Dark slate grey'],
+  ['rgb( 74,  104,   93)', 'Dark grullo green'],
+  ['rgb(  0,  128,  128)', 'Teal'],
+  ['rgb( 67,  170,  176)', 'Turquoise'],
+  ['rgb(109,  174,  199)', 'Cerulean frost'],
+  ['rgb(173,  217,  186)', 'Tiffany green'],
+  ['rgb( 22,   34,   29)', 'Gray-asparagus'],
+  ['rgb( 36,   48,   45)', 'Medium dark teal'],
+  ['rgb( 74,  104,   93)', 'Xanadu'],
+  ['rgb(119,  198,  121)', 'Mint'],
+  ['rgb(175,  205,  182)', 'Timberwolf'],
+  ['rgb(185,  245,  246)', 'Celeste'],
+  ['rgb(193,  255,  234)', 'Aquamarine'],
+  ['rgb( 29,   52,   35)', 'Cal Poly Pomona'],
+  ['rgb(  1,   68,   33)', 'Forest green'],
+  ['rgb( 42,  128,    0)', 'Napier green'],
+  ['rgb(128,  128,    0)', 'Olive'],
+  ['rgb( 65,  156,  105)', 'Sea green'],
+  ['rgb(189,  246,   29)', 'Green-yellow'],
+  ['rgb(231,  244,  134)', 'Bright chartreuse'],
+  ['rgb(138,   23,  137)', 'Purple'],
+  ['rgb( 78,   39,  138)', 'Violet'],
+  ['rgb(193,   75,  110)', 'Dark thulian pink'],
+  ['rgb(222,   49,   99)', 'Cerise'],
+  ['rgb(255,   20,  147)', 'Deep pink'],
+  ['rgb(255,  102,  204)', 'Rose pink'],
+  ['rgb(255,  203,  219)', 'Pink'],
+  ['rgb(255,  255,  255)', 'White'],
+  ['rgb(229,   17,    1)', 'RGB Red'],
+  ['rgb(  0,  255,    0)', 'RGB Green'],
+  ['rgb(  0,    0,  255)', 'RGB Blue'],
+  ['rgb(  0,  255,  255)', 'CMYK Cyan'],
+  ['rgb(255,    0,  255)', 'CMYK Magenta'],
+  ['rgb(255,  255,    0)', 'CMYK Yellow'],
+];
+
+// re-arrange to the right order for display
+let palReordered = [];
+for (let row = 0; row < 7; row++) {
+  for (let col = 0; col < 11; col++) {
+    palReordered.push(palette[col * 7 + row]);
+  }
+  palReordered.push(null); // null indicates a <br />
+}
+
+// Utility for converting base64 image to binary for upload
+// https://stackoverflow.com/questions/35940290/how-to-convert-base64-string-to-javascript-file-object-like-as-from-file-input-f
+function dataURLtoFile(dataurl, filename) {
+  let arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+    bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+  while(n--){
+    u8arr[n] = bstr.charCodeAt(n);
+  }
+  return new File([u8arr], filename, { type: mime });
+}
+
+const DOODLE_SIZES = {
+  normal: [500, 500, 'Square 500'],
+  tootbanner: [702, 330, 'Tootbanner'],
+  s640x480: [640, 480, '640×480 - 480p'],
+  s800x600: [800, 600, '800×600 - SVGA'],
+  s720x480: [720, 405, '720x405 - 16:9'],
+};
+
+
+const mapStateToProps = state => ({
+  options: state.getIn(['compose', 'doodle']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  /** Set options in the redux store */
+  setOpt: (opts) => dispatch(doodleSet(opts)),
+  /** Submit doodle for upload */
+  submit: (file) => dispatch(uploadCompose([file])),
+});
+
+/**
+ * Doodling dialog with drawing canvas
+ *
+ * Keyboard shortcuts:
+ * - Delete: Clear screen, fill with background color
+ * - Backspace, Ctrl+Z: Undo one step
+ * - Ctrl held while drawing: Use background color
+ * - Shift held while clicking screen: Use fill tool
+ *
+ * Palette:
+ * - Left mouse button: pick foreground
+ * - Ctrl + left mouse button: pick background
+ * - Right mouse button: pick background
+ */
+@connect(mapStateToProps, mapDispatchToProps)
+export default class DoodleModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.map,
+    onClose: PropTypes.func.isRequired,
+    setOpt: PropTypes.func.isRequired,
+    submit: PropTypes.func.isRequired,
+  };
+
+  //region Option getters/setters
+
+  /** Foreground color */
+  get fg () {
+    return this.props.options.get('fg');
+  }
+  set fg (value) {
+    this.props.setOpt({ fg: value });
+  }
+
+  /** Background color */
+  get bg () {
+    return this.props.options.get('bg');
+  }
+  set bg (value) {
+    this.props.setOpt({ bg: value });
+  }
+
+  /** Swap Fg and Bg for drawing */
+  get swapped () {
+    return this.props.options.get('swapped');
+  }
+  set swapped (value) {
+    this.props.setOpt({ swapped: value });
+  }
+
+  /** Mode - 'draw' or 'fill' */
+  get mode () {
+    return this.props.options.get('mode');
+  }
+  set mode (value) {
+    this.props.setOpt({ mode: value });
+  }
+
+  /** Base line weight */
+  get weight () {
+    return this.props.options.get('weight');
+  }
+  set weight (value) {
+    this.props.setOpt({ weight: value });
+  }
+
+  /** Drawing opacity */
+  get opacity () {
+    return this.props.options.get('opacity');
+  }
+  set opacity (value) {
+    this.props.setOpt({ opacity: value });
+  }
+
+  /** Adaptive stroke - change width with speed */
+  get adaptiveStroke () {
+    return this.props.options.get('adaptiveStroke');
+  }
+  set adaptiveStroke (value) {
+    this.props.setOpt({ adaptiveStroke: value });
+  }
+
+  /** Smoothing (for mouse drawing) */
+  get smoothing () {
+    return this.props.options.get('smoothing');
+  }
+  set smoothing (value) {
+    this.props.setOpt({ smoothing: value });
+  }
+
+  /** Size preset */
+  get size () {
+    return this.props.options.get('size');
+  }
+  set size (value) {
+    this.props.setOpt({ size: value });
+  }
+
+  //endregion
+
+  /** Key up handler */
+  handleKeyUp = (e) => {
+    if (e.target.nodeName === 'INPUT') return;
+
+    if (e.key === 'Delete') {
+      e.preventDefault();
+      this.handleClearBtn();
+      return;
+    }
+
+    if (e.key === 'Backspace' || (e.key === 'z' && (e.ctrlKey || e.metaKey))) {
+      e.preventDefault();
+      this.undo();
+    }
+
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = false;
+      this.swapped = false;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = false;
+      this.mode = 'draw';
+    }
+  };
+
+  /** Key down handler */
+  handleKeyDown = (e) => {
+    if (e.key === 'Control' || e.key === 'Meta') {
+      this.controlHeld = true;
+      this.swapped = true;
+    }
+
+    if (e.key === 'Shift') {
+      this.shiftHeld = true;
+      this.mode = 'fill';
+    }
+  };
+
+  /**
+   * Component installed in the DOM, do some initial set-up
+   */
+  componentDidMount () {
+    this.controlHeld = false;
+    this.shiftHeld = false;
+    this.swapped = false;
+    window.addEventListener('keyup', this.handleKeyUp, false);
+    window.addEventListener('keydown', this.handleKeyDown, false);
+  };
+
+  /**
+   * Tear component down
+   */
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp, false);
+    window.removeEventListener('keydown', this.handleKeyDown, false);
+    if (this.sketcher) this.sketcher.destroy();
+  }
+
+  /**
+   * Set reference to the canvas element.
+   * This is called during component init
+   *
+   * @param elem - canvas element
+   */
+  setCanvasRef = (elem) => {
+    this.canvas = elem;
+    if (elem) {
+      elem.addEventListener('dirty', () => {
+        this.saveUndo();
+        this.sketcher._dirty = false;
+      });
+
+      elem.addEventListener('click', () => {
+        // sketcher bug - does not fire dirty on fill
+        if (this.mode === 'fill') {
+          this.saveUndo();
+        }
+      });
+
+      // prevent context menu
+      elem.addEventListener('contextmenu', (e) => {
+        e.preventDefault();
+      });
+
+      elem.addEventListener('mousedown', (e) => {
+        if (e.button === 2) {
+          this.swapped = true;
+        }
+      });
+
+      elem.addEventListener('mouseup', (e) => {
+        if (e.button === 2) {
+          this.swapped = this.controlHeld;
+        }
+      });
+
+      this.initSketcher(elem);
+      this.mode = 'draw'; // Reset mode - it's confusing if left at 'fill'
+    }
+  };
+
+  /**
+   * Set up the sketcher instance
+   *
+   * @param canvas - canvas element. Null if we're just resizing
+   */
+  initSketcher (canvas = null) {
+    const sizepreset = DOODLE_SIZES[this.size];
+
+    if (this.sketcher) this.sketcher.destroy();
+    this.sketcher = new Atrament(canvas || this.canvas, sizepreset[0], sizepreset[1]);
+
+    if (canvas) {
+      this.ctx = this.sketcher.context;
+      this.updateSketcherSettings();
+    }
+
+    this.clearScreen();
+  }
+
+  /**
+   * Done button handler
+   */
+  onDoneButton = () => {
+    const dataUrl = this.sketcher.toImage();
+    const file = dataURLtoFile(dataUrl, 'doodle.png');
+    this.props.submit(file);
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Cancel button handler
+   */
+  onCancelButton = () => {
+    if (this.undos.length > 1 && !confirm('Discard doodle? All changes will be lost!')) {
+      return;
+    }
+
+    this.props.onClose(); // close dialog
+  };
+
+  /**
+   * Update sketcher options based on state
+   */
+  updateSketcherSettings () {
+    if (!this.sketcher) return;
+
+    if (this.oldSize !== this.size) this.initSketcher();
+
+    this.sketcher.color = (this.swapped ? this.bg : this.fg);
+    this.sketcher.opacity = this.opacity;
+    this.sketcher.weight = this.weight;
+    this.sketcher.mode = this.mode;
+    this.sketcher.smoothing = this.smoothing;
+    this.sketcher.adaptiveStroke = this.adaptiveStroke;
+
+    this.oldSize = this.size;
+  }
+
+  /**
+   * Fill screen with background color
+   */
+  clearScreen = () => {
+    this.ctx.fillStyle = this.bg;
+    this.ctx.fillRect(-1, -1, this.canvas.width+2, this.canvas.height+2);
+    this.undos = [];
+
+    this.doSaveUndo();
+  };
+
+  /**
+   * Undo one step
+   */
+  undo = () => {
+    if (this.undos.length > 1) {
+      this.undos.pop();
+      const buf = this.undos.pop();
+
+      this.sketcher.clear();
+      this.ctx.putImageData(buf, 0, 0);
+      this.doSaveUndo();
+    }
+  };
+
+  /**
+   * Save canvas content into the undo buffer immediately
+   */
+  doSaveUndo = () => {
+    this.undos.push(this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height));
+  };
+
+  /**
+   * Called on each canvas change.
+   * Saves canvas content to the undo buffer after some period of inactivity.
+   */
+  saveUndo = debounce(() => {
+    this.doSaveUndo();
+  }, 100);
+
+  /**
+   * Palette left click.
+   * Selects Fg color (or Bg, if Control/Meta is held)
+   *
+   * @param e - event
+   */
+  onPaletteClick = (e) => {
+    const c = e.target.dataset.color;
+
+    if (this.controlHeld) {
+      this.bg = c;
+    } else {
+      this.fg = c;
+    }
+
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Palette right click.
+   * Selects Bg color
+   *
+   * @param e - event
+   */
+  onPaletteRClick = (e) => {
+    this.bg = e.target.dataset.color;
+    e.target.blur();
+    e.preventDefault();
+  };
+
+  /**
+   * Handle click on the Draw mode button
+   *
+   * @param e - event
+   */
+  setModeDraw = (e) => {
+    this.mode = 'draw';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on the Fill mode button
+   *
+   * @param e - event
+   */
+  setModeFill = (e) => {
+    this.mode = 'fill';
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Smooth checkbox
+   *
+   * @param e - event
+   */
+  tglSmooth = (e) => {
+    this.smoothing = !this.smoothing;
+    e.target.blur();
+  };
+
+  /**
+   * Handle click on Adaptive checkbox
+   *
+   * @param e - event
+   */
+  tglAdaptive = (e) => {
+    this.adaptiveStroke = !this.adaptiveStroke;
+    e.target.blur();
+  };
+
+  /**
+   * Handle change of the Weight input field
+   *
+   * @param e - event
+   */
+  setWeight = (e) => {
+    this.weight = +e.target.value || 1;
+  };
+
+  /**
+   * Set size - clalback from the select box
+   *
+   * @param e - event
+   */
+  changeSize = (e) => {
+    let newSize = e.target.value;
+    if (newSize === this.oldSize) return;
+
+    if (this.undos.length > 1 && !confirm('Change size? This will erase your drawing!')) {
+      return;
+    }
+
+    this.size = newSize;
+  };
+
+  handleClearBtn = () => {
+    if (this.undos.length > 1 && !confirm('Clear screen? This will erase your drawing!')) {
+      return;
+    }
+
+    this.clearScreen();
+  };
+
+  /**
+   * Render the component
+   */
+  render () {
+    this.updateSketcherSettings();
+
+    return (
+      <div className='modal-root__modal doodle-modal'>
+        <div className='doodle-modal__container'>
+          <canvas ref={this.setCanvasRef} />
+        </div>
+
+        <div className='doodle-modal__action-bar'>
+          <div className='doodle-toolbar'>
+            <Button text='Done' onClick={this.onDoneButton} />
+            <Button text='Cancel' onClick={this.onCancelButton} />
+          </div>
+          <div className='filler' />
+          <div className='doodle-toolbar with-inputs'>
+            <div>
+              <label htmlFor='dd_smoothing'>Smoothing</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_smoothing' onChange={this.tglSmooth} checked={this.smoothing} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_adaptive'>Adaptive</label>
+              <span className='val'>
+                <input type='checkbox' id='dd_adaptive' onChange={this.tglAdaptive} checked={this.adaptiveStroke} />
+              </span>
+            </div>
+            <div>
+              <label htmlFor='dd_weight'>Weight</label>
+              <span className='val'>
+                <input type='number' min={1} id='dd_weight' value={this.weight} onChange={this.setWeight} />
+              </span>
+            </div>
+            <div>
+              <select aria-label='Canvas size' onInput={this.changeSize} defaultValue={this.size}>
+                { Object.values(mapValues(DOODLE_SIZES, (val, k) =>
+                  <option key={k} value={k}>{val[2]}</option>
+                )) }
+              </select>
+            </div>
+          </div>
+          <div className='doodle-toolbar'>
+            <IconButton icon='pencil' title='Draw' label='Draw' onClick={this.setModeDraw} size={18} active={this.mode === 'draw'} inverted />
+            <IconButton icon='bath' title='Fill' label='Fill' onClick={this.setModeFill} size={18} active={this.mode === 'fill'} inverted />
+            <IconButton icon='undo' title='Undo' label='Undo' onClick={this.undo} size={18} inverted />
+            <IconButton icon='trash' title='Clear' label='Clear' onClick={this.handleClearBtn} size={18} inverted />
+          </div>
+          <div className='doodle-palette'>
+            {
+              palReordered.map((c, i) =>
+                c === null ?
+                  <br key={i} /> :
+                  <button
+                    key={i}
+                    style={{ backgroundColor: c[0] }}
+                    onClick={this.onPaletteClick}
+                    onContextMenu={this.onPaletteRClick}
+                    data-color={c[0]}
+                    title={c[1]}
+                    className={classNames({
+                      'foreground': this.fg === c[0],
+                      'background': this.bg === c[0],
+                    })}
+                  />
+              )
+            }
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/drawer_loading.js b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/drawer_loading.js
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const DrawerLoading = () => (
+  <div className='drawer'>
+    <div className='drawer__pager'>
+      <div className='drawer__inner' />
+    </div>
+  </div>
+);
+
+export default DrawerLoading;
diff --git a/app/javascript/themes/glitch/features/ui/components/embed_modal.js b/app/javascript/themes/glitch/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..1afffb51b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/embed_modal.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import axios from 'axios';
+
+@injectIntl
+export default class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    axios.post('/api/web/embed', { url }).then(res => {
+      this.setState({ loading: false, oembed: res.data });
+
+      const iframeDocument = this.iframe.contentWindow.document;
+
+      iframeDocument.open();
+      iframeDocument.write(res.data.html);
+      iframeDocument.close();
+
+      iframeDocument.body.style.margin = 0;
+      this.iframe.width  = iframeDocument.body.scrollWidth;
+      this.iframe.height = iframeDocument.body.scrollHeight;
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  }
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  }
+
+  render () {
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal embed-modal'>
+        <h4><FormattedMessage id='status.embed' defaultMessage='Embed' /></h4>
+
+        <div className='embed-modal__container'>
+          <p className='hint'>
+            <FormattedMessage id='embed.instructions' defaultMessage='Embed this status on your website by copying the code below.' />
+          </p>
+
+          <input
+            type='text'
+            className='embed-modal__html'
+            readOnly
+            value={oembed && oembed.html || ''}
+            onClick={this.handleTextareaClick}
+          />
+
+          <p className='hint'>
+            <FormattedMessage id='embed.preview' defaultMessage='Here is what it will look like:' />
+          </p>
+
+          <iframe
+            className='embed-modal__iframe'
+            frameBorder='0'
+            ref={this.setIframeRef}
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/image_loader.js b/app/javascript/themes/glitch/features/ui/components/image_loader.js
new file mode 100644
index 000000000..aad594380
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/image_loader.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+export default class ImageLoader extends React.PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    previewSrc: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+  }
+
+  static defaultProps = {
+    alt: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    loading: true,
+    error: false,
+  }
+
+  removers = [];
+
+  get canvasContext() {
+    if (!this.canvas) {
+      return null;
+    }
+    this._canvasContext = this._canvasContext || this.canvas.getContext('2d');
+    return this._canvasContext;
+  }
+
+  componentDidMount () {
+    this.loadImage(this.props);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.src !== nextProps.src) {
+      this.loadImage(nextProps);
+    }
+  }
+
+  loadImage (props) {
+    this.removeEventListeners();
+    this.setState({ loading: true, error: false });
+    Promise.all([
+      this.loadPreviewCanvas(props),
+      this.hasSize() && this.loadOriginalImage(props),
+    ].filter(Boolean))
+      .then(() => {
+        this.setState({ loading: false, error: false });
+        this.clearPreviewCanvas();
+      })
+      .catch(() => this.setState({ loading: false, error: true }));
+  }
+
+  loadPreviewCanvas = ({ previewSrc, width, height }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      this.canvasContext.drawImage(image, 0, 0, width, height);
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = previewSrc;
+    this.removers.push(removeEventListeners);
+  })
+
+  clearPreviewCanvas () {
+    const { width, height } = this.canvas;
+    this.canvasContext.clearRect(0, 0, width, height);
+  }
+
+  loadOriginalImage = ({ src }) => new Promise((resolve, reject) => {
+    const image = new Image();
+    const removeEventListeners = () => {
+      image.removeEventListener('error', handleError);
+      image.removeEventListener('load', handleLoad);
+    };
+    const handleError = () => {
+      removeEventListeners();
+      reject();
+    };
+    const handleLoad = () => {
+      removeEventListeners();
+      resolve();
+    };
+    image.addEventListener('error', handleError);
+    image.addEventListener('load', handleLoad);
+    image.src = src;
+    this.removers.push(removeEventListeners);
+  });
+
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
+
+  hasSize () {
+    const { width, height } = this.props;
+    return typeof width === 'number' && typeof height === 'number';
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  render () {
+    const { alt, src, width, height } = this.props;
+    const { loading } = this.state;
+
+    const className = classNames('image-loader', {
+      'image-loader--loading': loading,
+      'image-loader--amorphous': !this.hasSize(),
+    });
+
+    return (
+      <div className={className}>
+        <canvas
+          className='image-loader__preview-canvas'
+          width={width}
+          height={height}
+          ref={this.setCanvasRef}
+          style={{ opacity: loading ? 1 : 0 }}
+        />
+
+        {!loading && (
+          <img
+            alt={alt}
+            className='image-loader__img'
+            src={src}
+            width={width}
+            height={height}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/media_modal.js b/app/javascript/themes/glitch/features/ui/components/media_modal.js
new file mode 100644
index 000000000..1dad972b2
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/media_modal.js
@@ -0,0 +1,126 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ExtendedVideoPlayer from 'themes/glitch/components/extended_video_player';
+import { defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'themes/glitch/components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+@injectIntl
+export default class MediaModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+    index: PropTypes.number.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: null,
+  };
+
+  handleSwipe = (index) => {
+    this.setState({ index: index % this.props.media.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.getIndex() + 1) % this.props.media.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.media.size + this.getIndex() - 1) % this.props.media.size });
+  }
+
+  handleChangeIndex = (e) => {
+    const index = Number(e.currentTarget.getAttribute('data-index'));
+    this.setState({ index: index % 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();
+    let pagination = [];
+
+    const leftNav  = media.size > 1 && <button tabIndex='0' className='modal-container__nav modal-container__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><i className='fa fa-fw fa-chevron-left' /></button>;
+    const rightNav = media.size > 1 && <button tabIndex='0' className='modal-container__nav  modal-container__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><i className='fa fa-fw fa-chevron-right' /></button>;
+
+    if (media.size > 1) {
+      pagination = media.map((item, i) => {
+        const classes = ['media-modal__button'];
+        if (i === index) {
+          classes.push('media-modal__button--active');
+        }
+        return (<li className='media-modal__page-dot' key={i}><button tabIndex='0' className={classes.join(' ')} onClick={this.handleChangeIndex} data-index={i}>{i + 1}</button></li>);
+      });
+    }
+
+    const content = media.map((image) => {
+      const width  = image.getIn(['meta', 'original', 'width']) || null;
+      const height = image.getIn(['meta', 'original', 'height']) || null;
+
+      if (image.get('type') === 'image') {
+        return <ImageLoader previewSrc={image.get('preview_url')} src={image.get('url')} width={width} height={height} alt={image.get('description')} key={image.get('preview_url')} />;
+      } else if (image.get('type') === 'gifv') {
+        return <ExtendedVideoPlayer src={image.get('url')} muted controls={false} width={width} height={height} key={image.get('preview_url')} alt={image.get('description')} />;
+      }
+
+      return null;
+    }).toArray();
+
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
+    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} />
+          <ReactSwipeableViews containerStyle={containerStyle} onChangeIndex={this.handleSwipe} index={index}>
+            {content}
+          </ReactSwipeableViews>
+        </div>
+        <ul className='media-modal__pagination'>
+          {pagination}
+        </ul>
+
+        {rightNav}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/modal_loading.js b/app/javascript/themes/glitch/features/ui/components/modal_loading.js
new file mode 100644
index 000000000..e14d20fbb
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/modal_loading.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import LoadingIndicator from 'themes/glitch/components/loading_indicator';
+
+// Keep the markup in sync with <BundleModalError />
+// (make sure they have the same dimensions)
+const ModalLoading = () => (
+  <div className='modal-root__modal error-modal'>
+    <div className='error-modal__body'>
+      <LoadingIndicator />
+    </div>
+    <div className='error-modal__footer'>
+      <div>
+        <button className='error-modal__nav onboarding-modal__skip' />
+      </div>
+    </div>
+  </div>
+);
+
+export default ModalLoading;
diff --git a/app/javascript/themes/glitch/features/ui/components/modal_root.js b/app/javascript/themes/glitch/features/ui/components/modal_root.js
new file mode 100644
index 000000000..fbe794170
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/modal_root.js
@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import BundleContainer from '../containers/bundle_container';
+import BundleModalError from './bundle_modal_error';
+import ModalLoading from './modal_loading';
+import ActionsModal from './actions_modal';
+import MediaModal from './media_modal';
+import VideoModal from './video_modal';
+import BoostModal from './boost_modal';
+import DoodleModal from './doodle_modal';
+import ConfirmationModal from './confirmation_modal';
+import {
+  OnboardingModal,
+  MuteModal,
+  ReportModal,
+  SettingsModal,
+  EmbedModal,
+} from 'themes/glitch/util/async-components';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': () => Promise.resolve({ default: MediaModal }),
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': () => Promise.resolve({ default: VideoModal }),
+  'BOOST': () => Promise.resolve({ default: BoostModal }),
+  'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+  'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+  'MUTE': MuteModal,
+  'REPORT': ReportModal,
+  'SETTINGS': SettingsModal,
+  'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    type: PropTypes.string,
+    props: PropTypes.object,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  state = {
+    revealed: false,
+  };
+
+  handleKeyUp = (e) => {
+    if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
+         && !!this.props.type && !this.props.props.noEsc) {
+      this.props.onClose();
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keyup', this.handleKeyUp, false);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (!!nextProps.type && !this.props.type) {
+      this.activeElement = document.activeElement;
+
+      this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
+    } else if (!nextProps.type) {
+      this.setState({ revealed: false });
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (!this.props.type && !!prevProps.type) {
+      this.getSiblings().forEach(sibling => sibling.removeAttribute('inert'));
+      this.activeElement.focus();
+      this.activeElement = null;
+    }
+    if (this.props.type) {
+      requestAnimationFrame(() => {
+        this.setState({ revealed: true });
+      });
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keyup', this.handleKeyUp);
+  }
+
+  getSiblings = () => {
+    return Array(...this.node.parentElement.childNodes).filter(node => node !== this.node);
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  renderLoading = modalId => () => {
+    return ['MEDIA', 'VIDEO', 'BOOST', 'DOODLE', 'CONFIRM', 'ACTIONS'].indexOf(modalId) === -1 ? <ModalLoading /> : null;
+  }
+
+  renderError = (props) => {
+    const { onClose } = this.props;
+
+    return <BundleModalError {...props} onClose={onClose} />;
+  }
+
+  render () {
+    const { type, props, onClose } = this.props;
+    const { revealed } = this.state;
+    const visible = !!type;
+
+    if (!visible) {
+      return (
+        <div className='modal-root' ref={this.setRef} style={{ opacity: 0 }} />
+      );
+    }
+
+    return (
+      <div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
+        <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
+          <div role='presentation' className='modal-root__overlay' onClick={onClose} />
+          <div role='dialog' className='modal-root__container'>
+            {
+              visible ?
+                (<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+                  {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+                </BundleContainer>) :
+              null
+            }
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/mute_modal.js b/app/javascript/themes/glitch/features/ui/components/mute_modal.js
new file mode 100644
index 000000000..ffccdc84d
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/mute_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import Button from 'themes/glitch/components/button';
+import { closeModal } from 'themes/glitch/actions/modal';
+import { muteAccount } from 'themes/glitch/actions/accounts';
+import { toggleHideNotifications } from 'themes/glitch/actions/mutes';
+
+
+const mapStateToProps = state => {
+  return {
+    isSubmitting: state.getIn(['reports', 'new', 'isSubmitting']),
+    account: state.getIn(['mutes', 'new', 'account']),
+    notifications: state.getIn(['mutes', 'new', 'notifications']),
+  };
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account, notifications) {
+      dispatch(muteAccount(account.get('id'), notifications));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+
+    onToggleNotifications() {
+      dispatch(toggleHideNotifications());
+    },
+  };
+};
+
+@connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+export default class MuteModal extends React.PureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool.isRequired,
+    account: PropTypes.object.isRequired,
+    notifications: PropTypes.bool.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    onToggleNotifications: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account, this.props.notifications);
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  toggleNotifications = () => {
+    this.props.onToggleNotifications();
+  }
+
+  render () {
+    const { account, notifications } = this.props;
+
+    return (
+      <div className='modal-root__modal mute-modal'>
+        <div className='mute-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.mute.message'
+              defaultMessage='Are you sure you want to mute {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+          <div>
+            <label htmlFor='mute-modal__hide-notifications-checkbox'>
+              <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
+              {' '}
+              <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
+            </label>
+          </div>
+        </div>
+
+        <div className='mute-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='mute-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button onClick={this.handleClick} ref={this.setRef}>
+            <FormattedMessage id='confirmations.mute.confirm' defaultMessage='Mute' />
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..58875262e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/onboarding_modal.js
@@ -0,0 +1,323 @@
+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 ReactSwipeableViews from 'react-swipeable-views';
+import classNames from 'classnames';
+import Permalink from 'themes/glitch/components/permalink';
+import ComposeForm from 'themes/glitch/features/compose/components/compose_form';
+import Search from 'themes/glitch/features/compose/components/search';
+import NavigationBar from 'themes/glitch/features/compose/components/navigation_bar';
+import ColumnHeader from './column_header';
+import {
+  List as ImmutableList,
+  Map as ImmutableMap,
+} from 'immutable';
+import { me } from 'themes/glitch/util/initial_state';
+
+const noop = () => { };
+
+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 {domain}!' values={{ domain }} /></h1>
+      <p><FormattedMessage id='onboarding.page_one.federation' defaultMessage='{domain} is an "instance" of Mastodon. Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.' values={{ domain }} /></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 = ({ myAccount }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <NavigationBar onClose={noop} account={myAccount} />
+      </div>
+      <ComposeForm
+        text='Awoo! #introductions'
+        suggestions={ImmutableList()}
+        mentionedDomains={[]}
+        spoiler={false}
+        onChange={noop}
+        onSubmit={noop}
+        onPaste={noop}
+        onPickEmoji={noop}
+        onChangeSpoilerText={noop}
+        onClearSuggestions={noop}
+        onFetchSuggestions={noop}
+        onSuggestionSelected={noop}
+        onPrivacyChange={noop}
+        showSearch
+        settings={ImmutableMap.of('side_arm', 'none')}
+      />
+    </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 = {
+  myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ myAccount }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-three'>
+    <div className='figure non-interactive'>
+      <Search
+        value=''
+        onChange={noop}
+        onSubmit={noop}
+        onClear={noop}
+        onShow={noop}
+      />
+
+      <div className='pseudo-drawer'>
+        <NavigationBar onClose={noop} account={myAccount} />
+      </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 = {
+  myAccount: ImmutablePropTypes.map.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='{domain} runs on Glitchsoc. Glitchsoc is a friendly {fork} of {Mastodon}. Glitchsoc is fully compatible with all Mastodon apps and instances. Glitchsoc is free open-source software. You can report bugs, request features, or contribute to the code on {github}.' values={{ domain, fork: <a href='https://en.wikipedia.org/wiki/Fork_(software_development)' target='_blank' rel='noopener'>fork</a>, Mastodon: <a href='https://github.com/tootsuite/mastodon' target='_blank' rel='noopener'>Mastodon</a>, github: <a href='https://github.com/glitch-soc/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={{ domain, 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 => ({
+  myAccount: state.getIn(['accounts', me]),
+  admin: state.getIn(['accounts', state.getIn(['meta', 'admin'])]),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+@connect(mapStateToProps)
+@injectIntl
+export default class OnboardingModal extends React.PureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map.isRequired,
+    domain: PropTypes.string.isRequired,
+    admin: ImmutablePropTypes.map,
+  };
+
+  state = {
+    currentIndex: 0,
+  };
+
+  componentWillMount() {
+    const { myAccount, admin, domain, intl } = this.props;
+    this.pages = [
+      <PageOne acct={myAccount.get('acct')} domain={domain} />,
+      <PageTwo myAccount={myAccount} />,
+      <PageThree myAccount={myAccount} />,
+      <PageFour domain={domain} intl={intl} />,
+      <PageSix admin={admin} domain={domain} />,
+    ];
+  };
+
+  componentDidMount() {
+    window.addEventListener('keyup', this.handleKeyUp);
+  }
+
+  componentWillUnmount() {
+    window.addEventListener('keyup', this.handleKeyUp);
+  }
+
+  handleSkip = (e) => {
+    e.preventDefault();
+    this.props.onClose();
+  }
+
+  handleDot = (e) => {
+    const i = Number(e.currentTarget.getAttribute('data-index'));
+    e.preventDefault();
+    this.setState({ currentIndex: i });
+  }
+
+  handlePrev = () => {
+    this.setState(({ currentIndex }) => ({
+      currentIndex: Math.max(0, currentIndex - 1),
+    }));
+  }
+
+  handleNext = () => {
+    const { pages } = this;
+    this.setState(({ currentIndex }) => ({
+      currentIndex: Math.min(currentIndex + 1, pages.length - 1),
+    }));
+  }
+
+  handleSwipe = (index) => {
+    this.setState({ currentIndex: index });
+  }
+
+  handleKeyUp = ({ key }) => {
+    switch (key) {
+    case 'ArrowLeft':
+      this.handlePrev();
+      break;
+    case 'ArrowRight':
+      this.handleNext();
+      break;
+    }
+  }
+
+  handleClose = () => {
+    this.props.onClose();
+  }
+
+  render () {
+    const { pages } = this;
+    const { currentIndex } = this.state;
+    const hasMore = currentIndex < pages.length - 1;
+
+    const nextOrDoneBtn = hasMore ? (
+      <button
+        onClick={this.handleNext}
+        className='onboarding-modal__nav onboarding-modal__next'
+      >
+        <FormattedMessage id='onboarding.next' defaultMessage='Next' />
+      </button>
+    ) : (
+      <button
+        onClick={this.handleClose}
+        className='onboarding-modal__nav onboarding-modal__done'
+      >
+        <FormattedMessage id='onboarding.done' defaultMessage='Done' />
+      </button>
+    );
+
+    return (
+      <div className='modal-root__modal onboarding-modal'>
+        <ReactSwipeableViews index={currentIndex} onChangeIndex={this.handleSwipe} className='onboarding-modal__pager'>
+          {pages.map((page, i) => {
+            const className = classNames('onboarding-modal__page__wrapper', {
+              'onboarding-modal__page__wrapper--active': i === currentIndex,
+            });
+            return (
+              <div key={i} className={className}>{page}</div>
+            );
+          })}
+        </ReactSwipeableViews>
+
+        <div className='onboarding-modal__paginator'>
+          <div>
+            <button
+              onClick={this.handleSkip}
+              className='onboarding-modal__nav onboarding-modal__skip'
+            >
+              <FormattedMessage id='onboarding.skip' defaultMessage='Skip' />
+            </button>
+          </div>
+
+          <div className='onboarding-modal__dots'>
+            {pages.map((_, i) => {
+              const className = classNames('onboarding-modal__dot', {
+                active: i === currentIndex,
+              });
+              return (
+                <div
+                  key={`dot-${i}`}
+                  role='button'
+                  tabIndex='0'
+                  data-index={i}
+                  onClick={this.handleDot}
+                  className={className}
+                />
+              );
+            })}
+          </div>
+
+          <div>
+            {nextOrDoneBtn}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/report_modal.js b/app/javascript/themes/glitch/features/ui/components/report_modal.js
new file mode 100644
index 000000000..e6153948e
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/report_modal.js
@@ -0,0 +1,105 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { changeReportComment, submitReport } from 'themes/glitch/actions/reports';
+import { refreshAccountTimeline } from 'themes/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { makeGetAccount } from 'themes/glitch/selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from 'themes/glitch/features/report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Button from 'themes/glitch/components/button';
+
+const messages = defineMessages({
+  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: OrderedSet(state.getIn(['timelines', `account:${accountId}`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+@connect(makeMapStateToProps)
+@injectIntl
+export default class ReportModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool,
+    account: ImmutablePropTypes.map,
+    statusIds: ImmutablePropTypes.orderedSet.isRequired,
+    comment: PropTypes.string.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleCommentChange = (e) => {
+    this.props.dispatch(changeReportComment(e.target.value));
+  }
+
+  handleSubmit = () => {
+    this.props.dispatch(submitReport());
+  }
+
+  componentDidMount () {
+    this.props.dispatch(refreshAccountTimeline(this.props.account.get('id')));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(refreshAccountTimeline(nextProps.account.get('id')));
+    }
+  }
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    return (
+      <div className='modal-root__modal report-modal'>
+        <div className='report-modal__target'>
+          <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__statuses'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+
+          <div className='report-modal__comment'>
+            <textarea
+              className='setting-text light'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              disabled={isSubmitting}
+            />
+          </div>
+        </div>
+
+        <div className='report-modal__action-bar'>
+          <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/tabs_bar.js b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..ef5deae99
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/tabs_bar.js
@@ -0,0 +1,84 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink } from 'react-router-dom';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from 'themes/glitch/util/is_mobile';
+
+export const links = [
+  <NavLink className='tabs-bar__link primary' to='/statuses/new' data-preview-title-id='tabs_bar.compose' data-preview-icon='pencil' ><i className='fa fa-fw fa-pencil' /><FormattedMessage id='tabs_bar.compose' defaultMessage='Compose' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><i className='fa fa-fw fa-home' /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link primary' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><i className='fa fa-fw fa-bell' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
+
+  <NavLink className='tabs-bar__link secondary' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><i className='fa fa-fw fa-users' /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link secondary' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><i className='fa fa-fw fa-globe' /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+
+  <NavLink className='tabs-bar__link primary' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='asterisk' ><i className='fa fa-fw fa-asterisk' /></NavLink>,
+];
+
+export function getIndex (path) {
+  return links.findIndex(link => link.props.to === path);
+}
+
+export function getLink (index) {
+  return links[index].props.to;
+}
+
+@injectIntl
+export default class TabsBar extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  }
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+  }
+
+  setRef = ref => {
+    this.node = ref;
+  }
+
+  handleClick = (e) => {
+    // Only apply optimization for touch devices, which we assume are slower
+    // We thus avoid the 250ms delay for non-touch devices and the lag for touch devices
+    if (isUserTouching()) {
+      e.preventDefault();
+      e.persist();
+
+      requestAnimationFrame(() => {
+        const tabs = Array(...this.node.querySelectorAll('.tabs-bar__link'));
+        const currentTab = tabs.find(tab => tab.classList.contains('active'));
+        const nextTab = tabs.find(tab => tab.contains(e.target));
+        const { props: { to } } = links[Array(...this.node.childNodes).indexOf(nextTab)];
+
+
+        if (currentTab !== nextTab) {
+          if (currentTab) {
+            currentTab.classList.remove('active');
+          }
+
+          const listener = debounce(() => {
+            nextTab.removeEventListener('transitionend', listener);
+            this.context.router.history.push(to);
+          }, 50);
+
+          nextTab.addEventListener('transitionend', listener);
+          nextTab.classList.add('active');
+        }
+      });
+    }
+
+  }
+
+  render () {
+    const { intl: { formatMessage } } = this.props;
+
+    return (
+      <nav className='tabs-bar' ref={this.setRef}>
+        {links.map(link => React.cloneElement(link, { key: link.props.to, onClick: this.handleClick, 'aria-label': formatMessage({ id: link.props['data-preview-title-id'] }) }))}
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/upload_area.js b/app/javascript/themes/glitch/features/ui/components/upload_area.js
new file mode 100644
index 000000000..72a450215
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/upload_area.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'themes/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadArea extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    onClose: PropTypes.func,
+  };
+
+  handleKeyUp = (e) => {
+    const keyCode = e.keyCode;
+    if (this.props.active) {
+      switch(keyCode) {
+      case 27:
+        e.preventDefault();
+        e.stopPropagation();
+        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: `scale(${backgroundScale})` }} />
+              <div className='upload-area__content'><FormattedMessage id='upload_area.title' defaultMessage='Drag & drop to upload' /></div>
+            </div>
+          </div>
+        }
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/components/video_modal.js b/app/javascript/themes/glitch/features/ui/components/video_modal.js
new file mode 100644
index 000000000..91168c790
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/components/video_modal.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'themes/glitch/features/video';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    time: PropTypes.number,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  render () {
+    const { media, time, onClose } = this.props;
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div>
+          <Video
+            preview={media.get('preview_url')}
+            src={media.get('url')}
+            startTime={time}
+            onCloseVideo={onClose}
+            description={media.get('description')}
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/ui/containers/bundle_container.js b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js
new file mode 100644
index 000000000..e6f9afcf7
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/bundle_container.js
@@ -0,0 +1,19 @@
+import { connect } from 'react-redux';
+
+import Bundle from '../components/bundle';
+
+import { fetchBundleRequest, fetchBundleSuccess, fetchBundleFail } from 'themes/glitch/actions/bundles';
+
+const mapDispatchToProps = dispatch => ({
+  onFetch () {
+    dispatch(fetchBundleRequest());
+  },
+  onFetchSuccess () {
+    dispatch(fetchBundleSuccess());
+  },
+  onFetchFail (error) {
+    dispatch(fetchBundleFail(error));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(Bundle);
diff --git a/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js
new file mode 100644
index 000000000..95f95618b
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/columns_area_container.js
@@ -0,0 +1,8 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+});
+
+export default connect(mapStateToProps, null, null, { withRef: true })(ColumnsArea);
diff --git a/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/themes/glitch/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..4bb90fb68
--- /dev/null
+++ b/app/javascript/themes/glitch/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/themes/glitch/features/ui/containers/modal_container.js b/app/javascript/themes/glitch/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..c26f19886
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from 'themes/glitch/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/themes/glitch/features/ui/containers/notifications_container.js b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..5bd4017f5
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/notifications_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import { dismissAlert } from 'themes/glitch/actions/alerts';
+import { getAlerts } from 'themes/glitch/selectors';
+
+const mapStateToProps = state => ({
+  notifications: getAlerts(state),
+});
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(NotificationStack);
diff --git a/app/javascript/themes/glitch/features/ui/containers/status_list_container.js b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..807c82e16
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/containers/status_list_container.js
@@ -0,0 +1,73 @@
+import { connect } from 'react-redux';
+import StatusList from 'themes/glitch/components/status_list';
+import { scrollTopTimeline } from 'themes/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'lodash';
+import { me } from 'themes/glitch/util/initial_state';
+
+const makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+  (state)           => state.get('statuses'),
+], (columnSettings, statusIds, statuses) => {
+  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
+  let regex      = null;
+
+  try {
+    regex = rawRegex && new RegExp(rawRegex, 'i');
+  } catch (e) {
+    // Bad regex, don't affect filters
+  }
+
+  return 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 (showStatus && regex && statusForId.get('account') !== me) {
+      const searchIndex = statusForId.get('reblog') ? statuses.getIn([statusForId.get('reblog'), 'search_index']) : statusForId.get('search_index');
+      showStatus = !regex.test(searchIndex);
+    }
+
+    return showStatus;
+  });
+});
+
+const makeMapStateToProps = () => {
+  const getStatusIds = makeGetStatusIds();
+
+  const mapStateToProps = (state, { timelineId }) => ({
+    statusIds: getStatusIds(state, { type: timelineId }),
+    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+    hasMore: !!state.getIn(['timelines', timelineId, 'next']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId, loadMore }) => ({
+
+  onScrollToBottom: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+    loadMore();
+  }, 300, { leading: true }),
+
+  onScrollToTop: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, true));
+  }, 100),
+
+  onScroll: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+  }, 100),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/themes/glitch/features/ui/index.js b/app/javascript/themes/glitch/features/ui/index.js
new file mode 100644
index 000000000..b59a2e637
--- /dev/null
+++ b/app/javascript/themes/glitch/features/ui/index.js
@@ -0,0 +1,442 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import TabsBar from './components/tabs_bar';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { isMobile } from 'themes/glitch/util/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose } from 'themes/glitch/actions/compose';
+import { refreshHomeTimeline } from 'themes/glitch/actions/timelines';
+import { refreshNotifications } from 'themes/glitch/actions/notifications';
+import { clearHeight } from 'themes/glitch/actions/height_cache';
+import { WrappedSwitch, WrappedRoute } from 'themes/glitch/util/react_router_helpers';
+import UploadArea from './components/upload_area';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
+import {
+  Compose,
+  Status,
+  GettingStarted,
+  PublicTimeline,
+  CommunityTimeline,
+  AccountTimeline,
+  AccountGallery,
+  HomeTimeline,
+  Followers,
+  Following,
+  Reblogs,
+  Favourites,
+  DirectTimeline,
+  HashtagTimeline,
+  Notifications,
+  FollowRequests,
+  GenericNotFound,
+  FavouritedStatuses,
+  Blocks,
+  Mutes,
+  PinnedStatuses,
+} from 'themes/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import { me } from 'themes/glitch/util/initial_state';
+import { defineMessages, injectIntl } from 'react-intl';
+
+// Dummy import, to make sure that <Status /> ends up in the application bundle.
+// Without this it ends up in ~8 very commonly used bundles.
+import '../../../glitch/components/status';
+
+const messages = defineMessages({
+  beforeUnload: { id: 'ui.beforeunload', defaultMessage: 'Your draft will be lost if you leave Mastodon.' },
+});
+
+const mapStateToProps = state => ({
+  isComposing: state.getIn(['compose', 'is_composing']),
+  hasComposingText: state.getIn(['compose', 'text']) !== '',
+  layout: state.getIn(['local_settings', 'layout']),
+  isWide: state.getIn(['local_settings', 'stretch']),
+  navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+});
+
+const keyMap = {
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  focusColumn: ['1', '2', '3', '4', '5', '6', '7', '8', '9'],
+  reply: 'r',
+  favourite: 'f',
+  boost: 'b',
+  mention: 'm',
+  open: ['enter', 'o'],
+  openProfile: 'p',
+  moveDown: ['down', 'j'],
+  moveUp: ['up', 'k'],
+  back: 'backspace',
+  goToHome: 'g h',
+  goToNotifications: 'g n',
+  goToLocal: 'g l',
+  goToFederated: 'g t',
+  goToDirect: 'g d',
+  goToStart: 'g s',
+  goToFavourites: 'g f',
+  goToPinned: 'g p',
+  goToProfile: 'g u',
+  goToBlocked: 'g b',
+  goToMuted: 'g m',
+};
+
+@connect(mapStateToProps)
+@injectIntl
+@withRouter
+export default class UI extends React.Component {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    children: PropTypes.node,
+    layout: PropTypes.string,
+    isWide: PropTypes.bool,
+    systemFontUi: PropTypes.bool,
+    navbarUnder: PropTypes.bool,
+    isComposing: PropTypes.bool,
+    hasComposingText: PropTypes.bool,
+    location: PropTypes.object,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    width: window.innerWidth,
+    draggingOver: false,
+  };
+
+  handleBeforeUnload = (e) => {
+    const { intl, isComposing, hasComposingText } = this.props;
+
+    if (isComposing && hasComposingText) {
+      // Setting returnValue to any string causes confirmation dialog.
+      // Many browsers no longer display this text to users,
+      // but we set user-friendly message for other browsers, e.g. Edge.
+      e.returnValue = intl.formatMessage(messages.beforeUnload);
+    }
+  }
+
+  handleResize = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearHeight());
+
+    this.setState({ width: window.innerWidth });
+  }, 500, {
+    trailing: true,
+  });
+
+  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 });
+  }
+
+  handleServiceWorkerPostMessage = ({ data }) => {
+    if (data.type === 'navigate') {
+      this.context.router.history.push(data.path);
+    } else {
+      console.warn('Unknown message type:', data.type);
+    }
+  }
+
+  componentWillMount () {
+    window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+    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);
+
+    if ('serviceWorker' in  navigator) {
+      navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
+    }
+
+    this.props.dispatch(refreshHomeTimeline());
+    this.props.dispatch(refreshNotifications());
+  }
+
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
+    };
+  }
+
+  shouldComponentUpdate (nextProps) {
+    if (nextProps.isComposing !== this.props.isComposing) {
+      // Avoid expensive update just to toggle a class
+      this.node.classList.toggle('is-composing', nextProps.isComposing);
+      this.node.classList.toggle('navbar-under', nextProps.navbarUnder);
+
+      return false;
+    }
+
+    // Why isn't this working?!?
+    // return super.shouldComponentUpdate(nextProps, nextState);
+    return true;
+  }
+
+  componentDidUpdate (prevProps) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.columnsAreaNode.handleChildrenContentChange();
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    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;
+  }
+
+  setColumnsAreaRef = c => {
+    this.columnsAreaNode = c.getWrappedInstance().getWrappedInstance();
+  }
+
+  handleHotkeyNew = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.compose-form__autosuggest-wrapper textarea');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeySearch = e => {
+    e.preventDefault();
+
+    const element = this.node.querySelector('.search__input');
+
+    if (element) {
+      element.focus();
+    }
+  }
+
+  handleHotkeyForceNew = e => {
+    this.handleHotkeyNew(e);
+    this.props.dispatch(resetCompose());
+  }
+
+  handleHotkeyFocusColumn = e => {
+    const index  = (e.key * 1) + 1; // First child is drawer, skip that
+    const column = this.node.querySelector(`.column:nth-child(${index})`);
+
+    if (column) {
+      const status = column.querySelector('.focusable');
+
+      if (status) {
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    if (window.history && window.history.length === 1) {
+      this.context.router.history.push('/');
+    } else {
+      this.context.router.history.goBack();
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.context.router.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.context.router.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.context.router.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.context.router.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToDirect = () => {
+    this.context.router.history.push('/timelines/direct');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.context.router.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.context.router.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.context.router.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.context.router.history.push(`/accounts/${me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.context.router.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.context.router.history.push('/mutes');
+  }
+
+  render () {
+    const { width, draggingOver } = this.state;
+    const { children, layout, isWide, navbarUnder } = this.props;
+
+    const columnsClass = layout => {
+      switch (layout) {
+      case 'single':
+        return 'single-column';
+      case 'multiple':
+        return 'multi-columns';
+      default:
+        return 'auto-columns';
+      }
+    };
+
+    const className = classNames('ui', columnsClass(layout), {
+      'wide': isWide,
+      'system-font': this.props.systemFontUi,
+      'navbar-under': navbarUnder,
+    });
+
+    const handlers = {
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      focusColumn: this.handleHotkeyFocusColumn,
+      back: this.handleHotkeyBack,
+      goToHome: this.handleHotkeyGoToHome,
+      goToNotifications: this.handleHotkeyGoToNotifications,
+      goToLocal: this.handleHotkeyGoToLocal,
+      goToFederated: this.handleHotkeyGoToFederated,
+      goToDirect: this.handleHotkeyGoToDirect,
+      goToStart: this.handleHotkeyGoToStart,
+      goToFavourites: this.handleHotkeyGoToFavourites,
+      goToPinned: this.handleHotkeyGoToPinned,
+      goToProfile: this.handleHotkeyGoToProfile,
+      goToBlocked: this.handleHotkeyGoToBlocked,
+      goToMuted: this.handleHotkeyGoToMuted,
+    };
+
+    return (
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef}>
+        <div className={className} ref={this.setRef}>
+          {navbarUnder ? null : (<TabsBar />)}
+
+          <ColumnsAreaContainer ref={this.setColumnsAreaRef} singleColumn={isMobile(width, layout)}>
+            <WrappedSwitch>
+              <Redirect from='/' to='/getting-started' exact />
+              <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+              <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+              <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+              <WrappedRoute path='/timelines/public/local' component={CommunityTimeline} content={children} />
+              <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
+              <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+
+              <WrappedRoute path='/notifications' component={Notifications} content={children} />
+              <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+              <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+              <WrappedRoute path='/statuses/new' component={Compose} content={children} />
+              <WrappedRoute path='/statuses/:statusId' exact component={Status} content={children} />
+              <WrappedRoute path='/statuses/:statusId/reblogs' component={Reblogs} content={children} />
+              <WrappedRoute path='/statuses/:statusId/favourites' component={Favourites} content={children} />
+
+              <WrappedRoute path='/accounts/:accountId' exact component={AccountTimeline} content={children} />
+              <WrappedRoute path='/accounts/:accountId/followers' component={Followers} content={children} />
+              <WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
+              <WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
+
+              <WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
+              <WrappedRoute path='/blocks' component={Blocks} content={children} />
+              <WrappedRoute path='/mutes' component={Mutes} content={children} />
+
+              <WrappedRoute component={GenericNotFound} content={children} />
+            </WrappedSwitch>
+          </ColumnsAreaContainer>
+
+          <NotificationsContainer />
+          {navbarUnder ? (<TabsBar />) : null}
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/themes/glitch/features/video/index.js b/app/javascript/themes/glitch/features/video/index.js
new file mode 100644
index 000000000..0ecbe37c9
--- /dev/null
+++ b/app/javascript/themes/glitch/features/video/index.js
@@ -0,0 +1,288 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from 'themes/glitch/util/fullscreen';
+
+const messages = defineMessages({
+  play: { id: 'video.play', defaultMessage: 'Play' },
+  pause: { id: 'video.pause', defaultMessage: 'Pause' },
+  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
+  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
+  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
+  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
+  close: { id: 'video.close', defaultMessage: 'Close video' },
+  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
+  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
+});
+
+const findElementPosition = el => {
+  let box;
+
+  if (el.getBoundingClientRect && el.parentNode) {
+    box = el.getBoundingClientRect();
+  }
+
+  if (!box) {
+    return {
+      left: 0,
+      top: 0,
+    };
+  }
+
+  const docEl = document.documentElement;
+  const body  = document.body;
+
+  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+  const scrollLeft = window.pageXOffset || body.scrollLeft;
+  const left       = (box.left + scrollLeft) - clientLeft;
+
+  const clientTop = docEl.clientTop || body.clientTop || 0;
+  const scrollTop = window.pageYOffset || body.scrollTop;
+  const top       = (box.top + scrollTop) - clientTop;
+
+  return {
+    left: Math.round(left),
+    top: Math.round(top),
+  };
+};
+
+const getPointerPosition = (el, event) => {
+  const position = {};
+  const box = findElementPosition(el);
+  const boxW = el.offsetWidth;
+  const boxH = el.offsetHeight;
+  const boxY = box.top;
+  const boxX = box.left;
+
+  let pageY = event.pageY;
+  let pageX = event.pageX;
+
+  if (event.changedTouches) {
+    pageX = event.changedTouches[0].pageX;
+    pageY = event.changedTouches[0].pageY;
+  }
+
+  position.y = Math.max(0, Math.min(1, ((boxY - pageY) + boxH) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+@injectIntl
+export default class Video extends React.PureComponent {
+
+  static propTypes = {
+    preview: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    sensitive: PropTypes.bool,
+    startTime: PropTypes.number,
+    onOpenVideo: PropTypes.func,
+    onCloseVideo: PropTypes.func,
+    letterbox: PropTypes.bool,
+    fullwidth: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    progress: 0,
+    paused: true,
+    dragging: false,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: !this.props.sensitive,
+  };
+
+  setPlayerRef = c => {
+    this.player = c;
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  }
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({ progress: 100 * (this.video.currentTime / this.video.duration) });
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove, true);
+    document.addEventListener('mouseup', this.handleMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseMove, true);
+    document.addEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: true });
+    this.video.pause();
+    this.handleMouseMove(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove, true);
+    document.removeEventListener('mouseup', this.handleMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseMove, true);
+    document.removeEventListener('touchend', this.handleMouseUp, true);
+
+    this.setState({ dragging: false });
+    this.video.play();
+  }
+
+  handleMouseMove = throttle(e => {
+    const { x } = getPointerPosition(this.seek, e);
+    this.video.currentTime = this.video.duration * x;
+    this.setState({ progress: x * 100 });
+  }, 60);
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      this.video.play();
+    } else {
+      this.video.pause();
+    }
+  }
+
+  toggleFullscreen = () => {
+    if (isFullscreen()) {
+      exitFullscreen();
+    } else {
+      requestFullscreen(this.player);
+    }
+  }
+
+  componentDidMount () {
+    document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
+    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
+  }
+
+  handleFullscreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  handleMouseEnter = () => {
+    this.setState({ hovered: true });
+  }
+
+  handleMouseLeave = () => {
+    this.setState({ hovered: false });
+  }
+
+  toggleMute = () => {
+    this.video.muted = !this.video.muted;
+    this.setState({ muted: this.video.muted });
+  }
+
+  toggleReveal = () => {
+    if (this.state.revealed) {
+      this.video.pause();
+    }
+
+    this.setState({ revealed: !this.state.revealed });
+  }
+
+  handleLoadedData = () => {
+    if (this.props.startTime) {
+      this.video.currentTime = this.props.startTime;
+      this.video.play();
+    }
+  }
+
+  handleProgress = () => {
+    if (this.video.buffered.length > 0) {
+      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    }
+  }
+
+  handleOpenVideo = () => {
+    this.video.pause();
+    this.props.onOpenVideo(this.video.currentTime);
+  }
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  }
+
+  render () {
+    const { preview, src, width, height, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth } = this.props;
+    const { progress, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+
+    return (
+      <div className={classNames('video-player', { inactive: !revealed, inline: !fullscreen, fullscreen, letterbox, 'full-width': fullwidth })} ref={this.setPlayerRef} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
+        <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={startTime ? 'auto' : 'none'}
+          loop
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          width={width}
+          height={height}
+          onClick={this.togglePlay}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onTimeUpdate={this.handleTimeUpdate}
+          onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
+        />
+
+        <button className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
+          <span className='video-player__spoiler__title'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
+        </button>
+
+        <div className={classNames('video-player__controls', { active: paused || hovered })}>
+          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
+            <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
+            <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
+
+            <span
+              className={classNames('video-player__seek__handle', { active: dragging })}
+              tabIndex='0'
+              style={{ left: `${progress}%` }}
+            />
+          </div>
+
+          <div className='video-player__buttons left'>
+            <button aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><i className={classNames('fa fa-fw', { 'fa-play': paused, 'fa-pause': !paused })} /></button>
+            <button aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><i className={classNames('fa fa-fw', { 'fa-volume-off': muted, 'fa-volume-up': !muted })} /></button>
+            {!onCloseVideo && <button aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><i className='fa fa-fw fa-eye' /></button>}
+          </div>
+
+          <div className='video-player__buttons right'>
+            {(!fullscreen && onOpenVideo) && <button aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><i className='fa fa-fw fa-expand' /></button>}
+            {onCloseVideo && <button aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><i className='fa fa-fw fa-times' /></button>}
+            <button aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><i className={classNames('fa fa-fw', { 'fa-arrows-alt': !fullscreen, 'fa-compress': fullscreen })} /></button>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}