about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch/features
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch/features')
-rw-r--r--app/javascript/flavours/glitch/features/account/components/action_bar.js72
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js317
-rw-r--r--app/javascript/flavours/glitch/features/account/components/profile_column_header.js33
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/components/media_item.js159
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js188
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/header.js130
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js51
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js129
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js145
-rw-r--r--app/javascript/flavours/glitch/features/audio/index.js237
-rw-r--r--app/javascript/flavours/glitch/features/blocks/index.js79
-rw-r--r--app/javascript/flavours/glitch/features/bookmarked_statuses/index.js102
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js41
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js28
-rw-r--r--app/javascript/flavours/glitch/features/community_timeline/index.js135
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js24
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/character_counter.js25
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/compose_form.js366
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown.js235
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js239
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/header.js134
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/navigation_bar.js38
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/options.js367
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/poll_form.js165
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/publisher.js118
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/reply_indicator.js86
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search.js177
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/search_results.js134
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/text_icon_button.js43
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/textarea_icons.js59
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload.js57
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_form.js34
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/upload_progress.js43
-rw-r--r--app/javascript/flavours/glitch/features/compose/components/warning.js26
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js135
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/header_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/navigation_container.js11
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/options_container.js61
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js48
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js22
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_container.js35
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/search_results_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js63
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_container.js27
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js9
-rw-r--r--app/javascript/flavours/glitch/features/compose/containers/warning_container.js45
-rw-r--r--app/javascript/flavours/glitch/features/compose/index.js111
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js35
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js235
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js73
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js74
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js15
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/index.js178
-rw-r--r--app/javascript/flavours/glitch/features/directory/components/account_card.js191
-rw-r--r--app/javascript/flavours/glitch/features/directory/index.js171
-rw-r--r--app/javascript/flavours/glitch/features/domain_blocks/index.js77
-rw-r--r--app/javascript/flavours/glitch/features/emoji_picker/index.js466
-rw-r--r--app/javascript/flavours/glitch/features/favourited_statuses/index.js102
-rw-r--r--app/javascript/flavours/glitch/features/favourites/index.js98
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js49
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/follow_requests/index.js95
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js132
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js132
-rw-r--r--app/javascript/flavours/glitch/features/generic_not_found/index.js11
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/announcements.js455
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/components/trends.js46
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js20
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/getting_started/index.js190
-rw-r--r--app/javascript/flavours/glitch/features/getting_started_misc/index.js69
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js120
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js31
-rw-r--r--app/javascript/flavours/glitch/features/hashtag_timeline/index.js165
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js50
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/home_timeline/index.js162
-rw-r--r--app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js139
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/components/account.js43
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/components/list.js69
-rw-r--r--app/javascript/flavours/glitch/features/list_adder/index.js73
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/account.js56
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js70
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/components/search.js62
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/containers/account_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/containers/search_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/list_editor/index.js79
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js222
-rw-r--r--app/javascript/flavours/glitch/features/lists/components/new_list_form.js78
-rw-r--r--app/javascript/flavours/glitch/features/lists/index.js83
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/index.js65
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/index.js100
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js70
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js435
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/item/index.js112
-rw-r--r--app/javascript/flavours/glitch/features/mutes/index.js79
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js18
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js119
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/filter_bar.js102
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow.js99
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/follow_request.js130
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js150
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/overlay.js58
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js35
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js43
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/notification_container.js26
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js314
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js24
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js21
-rw-r--r--app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js78
-rw-r--r--app/javascript/flavours/glitch/features/pinned_statuses/index.js61
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js30
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js28
-rw-r--r--app/javascript/flavours/glitch/features/public_timeline/index.js138
-rw-r--r--app/javascript/flavours/glitch/features/reblogs/index.js99
-rw-r--r--app/javascript/flavours/glitch/features/report/components/status_check_box.js76
-rw-r--r--app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/search/index.js17
-rw-r--r--app/javascript/flavours/glitch/features/standalone/compose/index.js20
-rw-r--r--app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js90
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js100
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js217
-rw-r--r--app/javascript/flavours/glitch/features/status/components/card.js277
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js275
-rw-r--r--app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js160
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js610
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/actions_modal.js124
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/audio_modal.js53
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/block_modal.js103
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/boost_modal.js104
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle.js107
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js44
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js53
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column.js74
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_header.js38
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_link.js51
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_loading.js30
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/column_subheading.js16
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js240
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/compose_panel.js16
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js81
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/doodle_modal.js614
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/drawer_loading.js11
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/embed_modal.js97
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/favourite_modal.js87
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js338
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js39
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_loader.js160
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js71
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/list_panel.js55
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/media_modal.js220
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_loading.js20
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/modal_root.js97
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/mute_modal.js108
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/navigation_panel.js37
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js9
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js310
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/report_modal.js136
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/tabs_bar.js86
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/upload_area.js52
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/video_modal.js63
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/zoomable_image.js152
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/bundle_container.js19
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js18
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js8
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/modal_container.js16
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/notifications_container.js29
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js88
-rw-r--r--app/javascript/flavours/glitch/features/ui/index.js622
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js543
176 files changed, 18977 insertions, 0 deletions
diff --git a/app/javascript/flavours/glitch/features/account/components/action_bar.js b/app/javascript/flavours/glitch/features/account/components/action_bar.js
new file mode 100644
index 000000000..6576bff8e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/action_bar.js
@@ -0,0 +1,72 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { NavLink } from 'react-router-dom';
+import { injectIntl, FormattedMessage, FormattedNumber } from 'react-intl';
+import { me, isStaff } from 'flavours/glitch/util/initial_state';
+import { profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
+import Icon from 'flavours/glitch/components/icon';
+
+export default @injectIntl
+class ActionBar extends React.PureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  isStatusesPageActive = (match, location) => {
+    if (!match) {
+      return false;
+    }
+    return !location.pathname.match(/\/(followers|following)\/?$/);
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let extraInfo = '';
+
+    if (account.get('acct') !== account.get('username')) {
+      extraInfo = (
+        <div className='account__disclaimer'>
+          <Icon id='info-circle' fixedWidth /> <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>
+      );
+    }
+
+    return (
+      <div>
+        {extraInfo}
+
+        <div className='account__action-bar'>
+          <div className='account__action-bar-links'>
+            <NavLink isActive={this.isStatusesPageActive} activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}`}>
+              <FormattedMessage id='account.posts' defaultMessage='Posts' />
+              <strong><FormattedNumber value={account.get('statuses_count')} /></strong>
+            </NavLink>
+
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/following`}>
+              <FormattedMessage id='account.follows' defaultMessage='Follows' />
+              <strong><FormattedNumber value={account.get('following_count')} /></strong>
+            </NavLink>
+
+            <NavLink exact activeClassName='active' className='account__action-bar__tab' to={`/accounts/${account.get('id')}/followers`}>
+              <FormattedMessage id='account.followers' defaultMessage='Followers' />
+              <strong>{ account.get('followers_count') < 0 ? '-' : <FormattedNumber value={account.get('followers_count')} /> }</strong>
+            </NavLink>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
new file mode 100644
index 000000000..c7b54649c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -0,0 +1,317 @@
+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 { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state';
+import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import Avatar from 'flavours/glitch/components/avatar';
+import Button from 'flavours/glitch/components/button';
+import { shortNumberFormat } from 'flavours/glitch/util/numbers';
+import { NavLink } from 'react-router-dom';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+
+const messages = defineMessages({
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
+  linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
+  account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
+  mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
+  direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  block: { id: 'account.block', defaultMessage: 'Block @{name}' },
+  mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' },
+  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: 'Block domain {domain}' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+  hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
+  showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
+  pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
+  preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+  follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Blocked domains' },
+  mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
+  endorse: { id: 'account.endorse', defaultMessage: 'Feature on profile' },
+  unendorse: { id: 'account.unendorse', defaultMessage: 'Don\'t feature on profile' },
+  add_or_remove_from_list: { id: 'account.add_or_remove_from_list', defaultMessage: 'Add or Remove from lists' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+});
+
+const dateFormatOptions = {
+  month: 'short',
+  day: 'numeric',
+  year: 'numeric',
+  hour12: false,
+  hour: '2-digit',
+  minute: '2-digit',
+};
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    identity_props: ImmutablePropTypes.list,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    domain: PropTypes.string.isRequired,
+  };
+
+  openEditProfile = () => {
+    window.open(profileLink, '_blank');
+  }
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const { account, intl, domain, identity_proofs } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    let info        = [];
+    let actionBtn   = '';
+    let lockedIcon  = '';
+    let menu        = [];
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'followed_by'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.follows_you' defaultMessage='Follows you' /></span>);
+    }
+    else if (me !== account.get('id') && account.getIn(['relationship', 'blocking'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.blocked' defaultMessage='Blocked' /></span>);
+    }
+
+    if (me !== account.get('id') && account.getIn(['relationship', 'muting'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.muted' defaultMessage='Muted' /></span>);
+    } else if (me !== account.get('id') && account.getIn(['relationship', 'domain_blocking'])) {
+      info.push(<span className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>);
+    }
+
+    if (me !== account.get('id')) {
+      if (!account.get('relationship')) { // Wait until the relationship is loaded
+        actionBtn = '';
+      } else if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
+      } else if (!account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
+      } else if (account.getIn(['relationship', 'blocking'])) {
+        actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
+      }
+    } else if (profileLink) {
+      actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
+    }
+
+    if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
+      actionBtn = '';
+    }
+
+    if (account.get('locked')) {
+      lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
+    }
+
+    if (account.get('id') !== me) {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
+      menu.push(null);
+    }
+
+    if ('share' in navigator) {
+      menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
+      menu.push(null);
+    }
+
+    if (account.get('id') === me) {
+      if (profileLink) menu.push({ text: intl.formatMessage(messages.edit_profile), href: profileLink });
+      if (preferencesLink) menu.push({ text: intl.formatMessage(messages.preferences), href: preferencesLink });
+      menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
+      menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
+      menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
+      menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
+      menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
+    } else {
+      if (account.getIn(['relationship', 'following'])) {
+        if (!account.getIn(['relationship', 'muting'])) {
+          if (account.getIn(['relationship', 'showing_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 });
+          }
+        }
+
+        menu.push({ text: intl.formatMessage(account.getIn(['relationship', 'endorsed']) ? messages.unendorse : messages.endorse), action: this.props.onEndorseToggle });
+        menu.push({ text: intl.formatMessage(messages.add_or_remove_from_list), action: this.props.onAddToList });
+        menu.push(null);
+      }
+
+      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];
+
+      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 });
+      }
+    }
+
+    if (account.get('id') !== me && isStaff && accountAdminLink) {
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
+    }
+
+    const content          = { __html: account.get('note_emojified') };
+    const displayNameHtml = { __html: account.get('display_name_html') };
+    const fields          = account.get('fields');
+    const acct            = account.get('acct').indexOf('@') === -1 && domain ? `${account.get('acct')}@${domain}` : account.get('acct');
+
+    let badge;
+
+    if (account.get('bot')) {
+      badge = (<div className='account-role bot'><FormattedMessage id='account.badges.bot' defaultMessage='Bot' /></div>);
+    } else if (account.get('group')) {
+      badge = (<div className='account-role group'><FormattedMessage id='account.badges.group' defaultMessage='Group' /></div>);
+    } else {
+      badge = null;
+    }
+
+    return (
+      <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
+        <div className='account__header__image'>
+          <div className='account__header__info'>
+            {info}
+          </div>
+
+          <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />
+        </div>
+
+        <div className='account__header__bar'>
+          <div className='account__header__tabs'>
+            <a className='avatar' href={account.get('url')} rel='noopener noreferrer' target='_blank'>
+              <Avatar account={account} size={90} />
+            </a>
+
+            <div className='spacer' />
+
+            <div className='account__header__tabs__buttons'>
+              {actionBtn}
+
+              <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
+            </div>
+          </div>
+
+          <div className='account__header__tabs__name'>
+            <h1>
+              <span dangerouslySetInnerHTML={displayNameHtml} /> {badge}
+              <small>@{acct} {lockedIcon}</small>
+            </h1>
+          </div>
+
+          <div className='account__header__extra'>
+            <div className='account__header__bio'>
+              { (fields.size > 0 || identity_proofs.size > 0) && (
+                <div className='account__header__fields'>
+                  {identity_proofs.map((proof, i) => (
+                    <dl key={i}>
+                      <dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
+
+                      <dd className='verified'>
+                        <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
+                          <Icon id='check' className='verified__mark' />
+                        </span></a>
+                        <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
+                      </dd>
+                    </dl>
+                  ))}
+                  {fields.map((pair, i) => (
+                    <dl key={i}>
+                      <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
+ 
+                      <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
+                        {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
+                      </dd>
+                    </dl>
+                  ))}
+                </div>
+              )}
+
+              {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
+            </div>
+         </div>
+       </div>
+     </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js
new file mode 100644
index 000000000..17c08e375
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js
@@ -0,0 +1,33 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ColumnHeader from '../../../components/column_header';
+import { injectIntl, defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  profile: { id: 'column_header.profile', defaultMessage: 'Profile' },
+});
+
+export default @injectIntl
+class ProfileColumnHeader extends React.PureComponent {
+
+  static propTypes = {
+    onClick: PropTypes.func,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render() {
+    const { onClick, intl, multiColumn } = this.props;
+
+    return (
+      <ColumnHeader
+        icon='user-circle'
+        title={intl.formatMessage(messages.profile)}
+        onClick={onClick}
+        showBackButton
+        multiColumn={multiColumn}
+      />
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
new file mode 100644
index 000000000..f1cb3f9e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/components/media_item.js
@@ -0,0 +1,159 @@
+import { decode } from 'blurhash';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { autoPlayGif, displayMedia } from 'flavours/glitch/util/initial_state';
+import { isIOS } from 'flavours/glitch/util/is_mobile';
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+export default class MediaItem extends ImmutablePureComponent {
+
+  static propTypes = {
+    attachment: ImmutablePropTypes.map.isRequired,
+    displayWidth: PropTypes.number.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+  };
+
+  state = {
+    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
+    loaded: false,
+  };
+
+  componentDidMount () {
+    if (this.props.attachment.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    const hash   = this.props.attachment.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ loaded: true });
+  }
+
+  handleMouseEnter = e => {
+    if (this.hoverToPlay()) {
+      e.target.play();
+    }
+  }
+
+  handleMouseLeave = e => {
+    if (this.hoverToPlay()) {
+      e.target.pause();
+      e.target.currentTime = 0;
+    }
+  }
+
+  hoverToPlay () {
+    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
+  }
+
+  handleClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+
+      if (this.state.visible) {
+        this.props.onOpenMedia(this.props.attachment);
+      } else {
+        this.setState({ visible: true });
+      }
+    }
+  }
+
+  render () {
+    const { attachment, displayWidth } = this.props;
+    const { visible, loaded } = this.state;
+
+    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
+    const height = width;
+    const status = attachment.get('status');
+    const title = status.get('spoiler_text') || attachment.get('description');
+
+    let thumbnail = '';
+
+    if (attachment.get('type') === 'unknown') {
+      // Skip
+    } else if (attachment.get('type') === 'audio') {
+      thumbnail = (
+        <span className='account-gallery__item__icons'>
+          <Icon id='music' />
+        </span>
+      );
+    } else if (attachment.get('type') === 'image') {
+      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
+      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
+      const x      = ((focusX /  2) + .5) * 100;
+      const y      = ((focusY / -2) + .5) * 100;
+
+      thumbnail = (
+        <img
+          src={attachment.get('preview_url')}
+          alt={attachment.get('description')}
+          title={attachment.get('description')}
+          style={{ objectPosition: `${x}% ${y}%` }}
+          onLoad={this.handleImageLoad}
+        />
+      );
+    } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
+      const autoPlay = !isIOS() && autoPlayGif;
+      const label    = attachment.get('type') === 'video' ? <Icon id='play' /> : 'GIF';
+
+      thumbnail = (
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
+          <video
+            className='media-gallery__item-gifv-thumbnail'
+            aria-label={attachment.get('description')}
+            title={attachment.get('description')}
+            role='application'
+            src={attachment.get('url')}
+            onMouseEnter={this.handleMouseEnter}
+            onMouseLeave={this.handleMouseLeave}
+            autoPlay={autoPlay}
+            loop
+            muted
+          />
+
+          <span className='media-gallery__gifv__label'>{label}</span>
+        </div>
+      );
+    }
+
+    const icon = (
+      <span className='account-gallery__item__icons'>
+        <Icon id='eye-slash' />
+      </span>
+    );
+
+    return (
+      <div className='account-gallery__item' style={{ width, height }}>
+        <a className='media-gallery__item-thumbnail' href={status.get('url')} onClick={this.handleClick} title={title} target='_blank' rel='noopener noreferrer'>
+          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
+          {visible ? thumbnail : icon}
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
new file mode 100644
index 000000000..f5fe6c930
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -0,0 +1,188 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { expandAccountMediaTimeline } from 'flavours/glitch/actions/timelines';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { getAccountGallery } from 'flavours/glitch/selectors';
+import MediaItem from './components/media_item';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import { ScrollContainer } from 'react-router-scroll-4';
+import LoadMore from 'flavours/glitch/components/load_more';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const mapStateToProps = (state, props) => ({
+  isAccount: !!state.getIn(['accounts', props.params.accountId]),
+  attachments: getAccountGallery(state, props.params.accountId),
+  isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
+  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
+});
+
+class LoadMoreMedia extends ImmutablePureComponent {
+
+  static propTypes = {
+    maxId: PropTypes.string,
+    onLoadMore: PropTypes.func.isRequired,
+  };
+
+  handleLoadMore = () => {
+    this.props.onLoadMore(this.props.maxId);
+  }
+
+  render () {
+    return (
+      <LoadMore
+        disabled={this.props.disabled}
+        onClick={this.handleLoadMore}
+      />
+    );
+  }
+
+}
+
+export default @connect(mapStateToProps)
+class AccountGallery extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    attachments: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+  };
+
+  state = {
+    width: 323,
+  };
+
+  componentDidMount () {
+    this.props.dispatch(fetchAccount(this.props.params.accountId));
+    this.props.dispatch(expandAccountMediaTimeline(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(expandAccountMediaTimeline(this.props.params.accountId));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  handleScrollToBottom = () => {
+    if (this.props.hasMore) {
+      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
+    }
+  }
+
+  handleScroll = e => {
+    const { scrollTop, scrollHeight, clientHeight } = e.target;
+    const offset = scrollHeight - scrollTop - clientHeight;
+
+    if (150 > offset && !this.props.isLoading) {
+      this.handleScrollToBottom();
+    }
+  }
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
+  };
+
+  handleLoadOlder = e => {
+    e.preventDefault();
+    this.handleScrollToBottom();
+  }
+
+  shouldUpdateScroll = (prevRouterProps, { location }) => {
+    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
+    return !(location.state && location.state.mastodonModalOpen);
+  }
+
+  setColumnRef = c => {
+    this.column = c;
+  }
+
+  handleOpenMedia = attachment => {
+    if (attachment.get('type') === 'video') {
+      this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
+    } else if (attachment.get('type') === 'audio') {
+      this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status') }));
+    } else {
+      const media = attachment.getIn(['status', 'media_attachments']);
+      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
+
+      this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
+    }
+  }
+
+  handleRef = c => {
+    if (c) {
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
+  render () {
+    const { attachments, isLoading, hasMore, isAccount, multiColumn } = this.props;
+    const { width } = this.state;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!attachments && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let loadOlder = null;
+
+    if (hasMore && !(isLoading && attachments.size === 0)) {
+      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
+    }
+
+    return (
+      <Column ref={this.setColumnRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
+          <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
+            <HeaderContainer accountId={this.props.params.accountId} />
+
+            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
+              {attachments.map((attachment, index) => attachment === null ? (
+                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
+              ) : (
+                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
+              ))}
+
+              {loadOlder}
+            </div>
+
+            {isLoading && attachments.size === 0 && (
+              <div className='scrollable__append'>
+                <LoadingIndicator />
+              </div>
+            )}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/header.js b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
new file mode 100644
index 000000000..0faa8a424
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/header.js
@@ -0,0 +1,130 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import InnerHeader from 'flavours/glitch/features/account/components/header';
+import ActionBar from 'flavours/glitch/features/account/components/action_bar';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import { NavLink } from 'react-router-dom';
+import MovedNote from './moved_note';
+
+export default class Header extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map,
+    identity_proofs: ImmutablePropTypes.list,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMention: PropTypes.func.isRequired,
+    onDirect: PropTypes.func.isRequired,
+    onReblogToggle: PropTypes.func.isRequired,
+    onReport: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+    onBlockDomain: PropTypes.func.isRequired,
+    onUnblockDomain: PropTypes.func.isRequired,
+    onEndorseToggle: PropTypes.func.isRequired,
+    onAddToList: PropTypes.func.isRequired,
+    hideTabs: PropTypes.bool,
+    domain: PropTypes.string.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);
+  }
+
+  handleDirect = () => {
+    this.props.onDirect(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);
+  }
+
+  handleUnblockDomain = () => {
+    const domain = this.props.account.get('acct').split('@')[1];
+
+    if (!domain) return;
+
+    this.props.onUnblockDomain(domain);
+  }
+
+  handleEndorseToggle = () => {
+    this.props.onEndorseToggle(this.props.account);
+  }
+
+  handleAddToList = () => {
+    this.props.onAddToList(this.props.account);
+  }
+
+  render () {
+    const { account, hideTabs, identity_proofs } = this.props;
+
+    if (account === null) {
+      return null;
+    }
+
+    return (
+      <div className='account-timeline__header'>
+        {account.get('moved') && <MovedNote from={account} to={account.get('moved')} />}
+
+        <InnerHeader
+          account={account}
+          identity_proofs={identity_proofs}
+          onFollow={this.handleFollow}
+          onBlock={this.handleBlock}
+          onMention={this.handleMention}
+          onDirect={this.handleDirect}
+          onReblogToggle={this.handleReblogToggle}
+          onReport={this.handleReport}
+          onMute={this.handleMute}
+          onBlockDomain={this.handleBlockDomain}
+          onUnblockDomain={this.handleUnblockDomain}
+          onEndorseToggle={this.handleEndorseToggle}
+          onAddToList={this.handleAddToList}
+          domain={this.props.domain}
+        />
+
+        <ActionBar
+          account={account}
+        />
+
+        {!hideTabs && (
+          <div className='account__section-headline'>
+            <NavLink exact to={`/accounts/${account.get('id')}`}><FormattedMessage id='account.posts' defaultMessage='Toots' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/with_replies`}><FormattedMessage id='account.posts_with_replies' defaultMessage='Toots with replies' /></NavLink>
+            <NavLink exact to={`/accounts/${account.get('id')}/media`}><FormattedMessage id='account.media' defaultMessage='Media' /></NavLink>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
new file mode 100644
index 000000000..fcaa7b494
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/components/moved_note.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from '../../../components/avatar_overlay';
+import DisplayName from '../../../components/display_name';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class MovedNote extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    from: ImmutablePropTypes.map.isRequired,
+    to: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleAccountClick = e => {
+    if (e.button === 0) {
+      e.preventDefault();
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.to.get('id')}`, state);
+    }
+
+    e.stopPropagation();
+  }
+
+  render () {
+    const { from, to } = this.props;
+    const displayNameHtml = { __html: from.get('display_name_html') };
+
+    return (
+      <div className='account__moved-note'>
+        <div className='account__moved-note__message'>
+          <div className='account__moved-note__icon-wrapper'><Icon id='suitcase' className='account__moved-note__icon' fixedWidth /></div>
+          <FormattedMessage id='account.moved_to' defaultMessage='{name} has moved to:' values={{ name: <bdi><strong dangerouslySetInnerHTML={displayNameHtml} /></bdi> }} />
+        </div>
+
+        <a href={to.get('url')} onClick={this.handleAccountClick} className='detailed-status__display-name'>
+          <div className='detailed-status__display-avatar'><AvatarOverlay account={to} friend={from} /></div>
+          <DisplayName account={to} />
+        </a>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
new file mode 100644
index 000000000..fff5e097f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/containers/header_container.js
@@ -0,0 +1,129 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Header from '../components/header';
+import {
+  followAccount,
+  unfollowAccount,
+  unblockAccount,
+  unmuteAccount,
+  pinAccount,
+  unpinAccount,
+} from 'flavours/glitch/actions/accounts';
+import {
+  mentionCompose,
+  directCompose
+} from 'flavours/glitch/actions/compose';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { blockDomain, unblockDomain } from 'flavours/glitch/actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { unfollowModal } from 'flavours/glitch/util/initial_state';
+import { List as ImmutableList } from 'immutable';
+
+const messages = defineMessages({
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+  blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+    domain: state.getIn(['meta', 'domain']),
+    identity_proofs: state.getIn(['identity_proofs', accountId], ImmutableList()),
+  });
+
+  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(initBlockModal(account));
+    }
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onReblogToggle (account) {
+    if (account.getIn(['relationship', 'showing_reblogs'])) {
+      dispatch(followAccount(account.get('id'), false));
+    } else {
+      dispatch(followAccount(account.get('id'), true));
+    }
+  },
+
+  onEndorseToggle (account) {
+    if (account.getIn(['relationship', 'endorsed'])) {
+      dispatch(unpinAccount(account.get('id')));
+    } else {
+      dispatch(pinAccount(account.get('id')));
+    }
+  },
+
+  onReport (account) {
+    dispatch(initReport(account));
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+  onBlockDomain (domain) {
+    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)),
+    }));
+  },
+
+  onUnblockDomain (domain) {
+    dispatch(unblockDomain(domain));
+  },
+
+  onAddToList(account){
+    dispatch(openModal('LIST_ADDER', {
+      accountId: account.get('id'),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
new file mode 100644
index 000000000..a8e8aa7a8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -0,0 +1,145 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from 'flavours/glitch/actions/accounts';
+import { expandAccountFeaturedTimeline, expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from './containers/header_container';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+
+const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
+  const path = withReplies ? `${accountId}:with_replies` : accountId;
+
+  return {
+    remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']),
+    remoteUrl: state.getIn(['accounts', accountId, 'url']),
+    isAccount: !!state.getIn(['accounts', accountId]),
+    statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
+    featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
+    isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
+    hasMore:   state.getIn(['timelines', `account:${path}`, 'hasMore']),
+  };
+};
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class AccountTimeline extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list,
+    featuredStatusIds: ImmutablePropTypes.list,
+    isLoading: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    withReplies: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    const { params: { accountId }, withReplies } = this.props;
+
+    this.props.dispatch(fetchAccount(accountId));
+    this.props.dispatch(fetchAccountIdentityProofs(accountId));
+    if (!withReplies) {
+      this.props.dispatch(expandAccountFeaturedTimeline(accountId));
+    }
+    this.props.dispatch(expandAccountTimeline(accountId, { withReplies }));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if ((nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) || nextProps.withReplies !== this.props.withReplies) {
+      this.props.dispatch(fetchAccount(nextProps.params.accountId));
+      this.props.dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
+      if (!nextProps.withReplies) {
+        this.props.dispatch(expandAccountFeaturedTimeline(nextProps.params.accountId));
+      }
+      this.props.dispatch(expandAccountTimeline(nextProps.params.accountId, { withReplies: nextProps.params.withReplies }));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  handleLoadMore = maxId => {
+    this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <ColumnBackButton multiColumn={multiColumn} />
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!statusIds && isLoading) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    if (remote && statusIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef} name='account'>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <StatusList
+          prepend={<HeaderContainer accountId={this.props.params.accountId} />}
+          alwaysPrepend
+          append={remoteMessage}
+          scrollKey='account_timeline'
+          statusIds={statusIds}
+          featuredStatusIds={featuredStatusIds}
+          isLoading={isLoading}
+          hasMore={hasMore}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+          timelineId='account'
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/audio/index.js b/app/javascript/flavours/glitch/features/audio/index.js
new file mode 100644
index 000000000..ba3534492
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/audio/index.js
@@ -0,0 +1,237 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import WaveSurfer from 'wavesurfer.js';
+import { defineMessages, injectIntl } from 'react-intl';
+import { formatTime } from 'flavours/glitch/features/video';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { throttle } from 'lodash';
+
+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' },
+  download: { id: 'video.download', defaultMessage: 'Download file' },
+});
+
+export default @injectIntl
+class Audio extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    alt: PropTypes.string,
+    duration: PropTypes.number,
+    peaks: PropTypes.arrayOf(PropTypes.number),
+    height: PropTypes.number,
+    preload: PropTypes.bool,
+    editable: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    currentTime: 0,
+    duration: null,
+    paused: true,
+    muted: false,
+    volume: 0.5,
+  };
+
+  // hard coded in components.scss
+  // any way to get ::before values programatically?
+
+  volWidth = 50;
+
+  volOffset = 70;
+
+  volHandleOffset = v => {
+    const offset = v * this.volWidth + this.volOffset;
+    return (offset > 110) ? 110 : offset;
+  }
+
+  setVolumeRef = c => {
+    this.volume = c;
+  }
+
+  setWaveformRef = c => {
+    this.waveform = c;
+  }
+
+  componentDidMount () {
+    if (this.waveform) {
+      this._updateWaveform();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.waveform && prevProps.src !== this.props.src) {
+      this._updateWaveform();
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.wavesurfer = null;
+    }
+  }
+
+  _updateWaveform () {
+    const { src, height, duration, peaks, preload } = this.props;
+
+    const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
+    const waveColor     = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
+
+    if (this.wavesurfer) {
+      this.wavesurfer.destroy();
+      this.loaded = false;
+    }
+
+    const wavesurfer = WaveSurfer.create({
+      container: this.waveform,
+      height,
+      barWidth: 3,
+      cursorWidth: 0,
+      progressColor,
+      waveColor,
+      backend: 'MediaElement',
+      interact: preload,
+    });
+
+    wavesurfer.setVolume(this.state.volume);
+
+    if (preload) {
+      wavesurfer.load(src);
+      this.loaded = true;
+    } else {
+      wavesurfer.load(src, peaks, 'none', duration);
+      this.loaded = false;
+    }
+
+    wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
+    wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
+    wavesurfer.on('pause', () => this.setState({ paused: true }));
+    wavesurfer.on('play', () => this.setState({ paused: false }));
+    wavesurfer.on('volume', volume => this.setState({ volume }));
+    wavesurfer.on('mute', muted => this.setState({ muted }));
+
+    this.wavesurfer = wavesurfer;
+  }
+
+  togglePlay = () => {
+    if (this.state.paused) {
+      if (!this.props.preload && !this.loaded) {
+        this.wavesurfer.createBackend();
+        this.wavesurfer.createPeakCache();
+        this.wavesurfer.load(this.props.src);
+        this.wavesurfer.toggleInteraction();
+        this.wavesurfer.setVolume(this.state.volume);
+        this.loaded = true;
+      }
+
+      this.wavesurfer.play();
+      this.setState({ paused: false });
+    } else {
+      this.wavesurfer.pause();
+      this.setState({ paused: true });
+    }
+  }
+
+  toggleMute = () => {
+    this.wavesurfer.setMute(!this.state.muted);
+  }
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  }
+
+  handleMouseVolSlide = throttle(e => {
+    const rect = this.volume.getBoundingClientRect();
+    const x    = (e.clientX - rect.left) / this.volWidth; // x position within the element.
+
+    if(!isNaN(x)) {
+      let slideamt = x;
+
+      if (x > 1) {
+        slideamt = 1;
+      } else if(x < 0) {
+        slideamt = 0;
+      }
+
+      this.wavesurfer.setVolume(slideamt);
+    }
+  }, 60);
+
+  render () {
+    const { height, intl, alt, editable } = this.props;
+    const { paused, muted, volume, currentTime } = this.state;
+
+    const volumeWidth     = muted ? 0 : volume * this.volWidth;
+    const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+
+    return (
+      <div className={classNames('audio-player', { editable })}>
+        <div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
+        <div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
+
+        <div
+          className='audio-player__waveform'
+          aria-label={alt}
+          title={alt}
+          style={{ height }}
+          ref={this.setWaveformRef}
+        />
+
+        <div className='video-player__controls active'>
+          <div className='video-player__buttons-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                &nbsp;
+                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volumeHandleLoc}px` }}
+                />
+              </div>
+
+              <span>
+                <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                <span className='video-player__time-sep'>/</span>
+                <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
+              </span>
+            </div>
+
+            <div className='video-player__buttons right'>
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}>
+                <a className='video-player__download__icon' href={this.props.src} download>
+                  <Icon id={'download'} fixedWidth />
+                </a>
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/blocks/index.js b/app/javascript/flavours/glitch/features/blocks/index.js
new file mode 100644
index 000000000..4d0f58239
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/blocks/index.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import ScrollableList from '../../components/scrollable_list';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import { fetchBlocks, expandBlocks } from 'flavours/glitch/actions/blocks';
+import { defineMessages, injectIntl, FormattedMessage } 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']),
+  hasMore: !!state.getIn(['user_lists', 'blocks', 'next']),
+  isLoading: state.getIn(['user_lists', 'blocks', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchBlocks());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandBlocks());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.blocks' defaultMessage="You haven't blocked any users yet." />;
+
+    return (
+      <Column name='blocks' bindToDocument={!multiColumn} icon='ban' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollableList
+          scrollKey='blocks'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
new file mode 100644
index 000000000..58b9e6396
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/bookmarked_statuses/index.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import { fetchBookmarkedStatuses, expandBookmarkedStatuses } from 'flavours/glitch/actions/bookmarks';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import StatusList from 'flavours/glitch/components/status_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'column.bookmarks', defaultMessage: 'Bookmarks' },
+});
+
+const mapStateToProps = state => ({
+  statusIds: state.getIn(['status_lists', 'bookmarks', 'items']),
+  isLoading: state.getIn(['status_lists', 'bookmarks', 'isLoading'], true),
+  hasMore: !!state.getIn(['status_lists', 'bookmarks', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Bookmarks 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,
+    isLoading: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchBookmarkedStatuses());
+  }
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('BOOKMARKS', {}));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandBookmarkedStatuses());
+  }, 300, { leading: true })
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const pinned = !!columnId;
+
+    const emptyMessage = <FormattedMessage id='empty_column.bookmarked_statuses' defaultMessage="You don't have any bookmarked toots yet. When you bookmark one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='bookmarks'>
+        <ColumnHeader
+          icon='bookmark'
+          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={`bookmarked_statuses-${columnId}`}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js
new file mode 100644
index 000000000..69a4699ac
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/components/column_settings.js
@@ -0,0 +1,41 @@
+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 'flavours/glitch/components/setting_text';
+import SettingToggle from 'flavours/glitch/features/notifications/components/setting_toggle';
+
+const messages = defineMessages({
+  filter_regex: { id: 'home.column_settings.filter_regex', defaultMessage: 'Filter out by regular expressions' },
+  settings: { id: 'home.settings', defaultMessage: 'Column settings' },
+});
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+  };
+
+  render () {
+    const { settings, onChange, intl } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
+        </div>
+
+        <span className='column-settings__section'><FormattedMessage id='home.column_settings.advanced' defaultMessage='Advanced' /></span>
+
+        <div className='column-settings__row'>
+          <SettingText settings={settings} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..b892f08ad
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/containers/column_settings_container.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+  return {
+    settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'community']),
+  };
+};
+ 
+const mapDispatchToProps = (dispatch, { columnId }) => {
+  return {
+    onChange (key, checked) {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, key, checked));
+      } else {
+        dispatch(changeSetting(['community', ...key], checked));
+      }
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/community_timeline/index.js b/app/javascript/flavours/glitch/features/community_timeline/index.js
new file mode 100644
index 000000000..7341f9702
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/community_timeline/index.js
@@ -0,0 +1,135 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.community', defaultMessage: 'Local timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'community', 'other', 'onlyMedia']);
+  const timelineState = state.getIn(['timelines', `community${onlyMedia ? ':media' : ''}`]);
+
+  return {
+    hasUnread: !!timelineState && timelineState.get('unread') > 0,
+    onlyMedia,
+  };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
+class CommunityTimeline extends React.PureComponent {
+
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch, onlyMedia } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('COMMUNITY', { other: { onlyMedia } }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch, onlyMedia } = this.props;
+
+    dispatch(expandCommunityTimeline({ onlyMedia }));
+    this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.onlyMedia !== this.props.onlyMedia) {
+      const { dispatch, onlyMedia } = this.props;
+
+      this.disconnect();
+      dispatch(expandCommunityTimeline({ onlyMedia }));
+      this.disconnect = dispatch(connectCommunityStream({ onlyMedia }));
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = maxId => {
+    const { dispatch, onlyMedia } = this.props;
+
+    dispatch(expandCommunityTimeline({ maxId, onlyMedia }));
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, onlyMedia } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='local' bindToDocument={!multiColumn} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='users'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer columnId={columnId} />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`community_timeline-${columnId}`}
+          timelineId={`community${onlyMedia ? ':media' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.community' defaultMessage='The local timeline is empty. Write something publicly to get the ball rolling!' />}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
new file mode 100644
index 000000000..fb9bb5035
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/autosuggest_account.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/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='account small' title={account.get('acct')}>
+        <div className='account__avatar-wrapper'><Avatar account={account} size={24} /></div>
+        <DisplayName account={account} inline />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/character_counter.js b/app/javascript/flavours/glitch/features/compose/components/character_counter.js
new file mode 100644
index 000000000..0ecfc9141
--- /dev/null
+++ b/app/javascript/flavours/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/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
new file mode 100644
index 000000000..a7cb95222
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js
@@ -0,0 +1,366 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ReplyIndicatorContainer from '../containers/reply_indicator_container';
+import AutosuggestTextarea from '../../../components/autosuggest_textarea';
+import AutosuggestInput from '../../../components/autosuggest_input';
+import { defineMessages, injectIntl } from 'react-intl';
+import EmojiPicker from 'flavours/glitch/features/emoji_picker';
+import PollFormContainer from '../containers/poll_form_container';
+import UploadFormContainer from '../containers/upload_form_container';
+import WarningContainer from '../containers/warning_container';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { countableText } from 'flavours/glitch/util/counter';
+import OptionsContainer from '../containers/options_container';
+import Publisher from './publisher';
+import TextareaIcons from './textarea_icons';
+import { maxChars } from 'flavours/glitch/util/initial_state';
+import CharacterCounter from './character_counter';
+
+const messages = defineMessages({
+  placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
+  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
+                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
+  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
+                                defaultMessage: 'Send anyway' },
+  spoiler_placeholder: { id: 'compose_form.spoiler_placeholder', defaultMessage: 'Write your warning here' },
+});
+
+export default @injectIntl
+class ComposeForm extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    text: PropTypes.string,
+    suggestions: ImmutablePropTypes.list,
+    spoiler: PropTypes.bool,
+    privacy: PropTypes.string,
+    spoilerText: PropTypes.string,
+    focusDate: PropTypes.instanceOf(Date),
+    caretPosition: PropTypes.number,
+    preselectDate: PropTypes.instanceOf(Date),
+    isSubmitting: PropTypes.bool,
+    isChangingUpload: PropTypes.bool,
+    isUploading: PropTypes.bool,
+    onChange: PropTypes.func,
+    onSubmit: PropTypes.func,
+    onClearSuggestions: PropTypes.func,
+    onFetchSuggestions: PropTypes.func,
+    onSuggestionSelected: PropTypes.func,
+    onChangeSpoilerText: PropTypes.func,
+    onPaste: PropTypes.func,
+    onPickEmoji: PropTypes.func,
+    showSearch: PropTypes.bool,
+    anyMedia: PropTypes.bool,
+    singleColumn: PropTypes.bool,
+
+    advancedOptions: ImmutablePropTypes.map,
+    layout: PropTypes.string,
+    media: ImmutablePropTypes.list,
+    sideArm: PropTypes.string,
+    sensitive: PropTypes.bool,
+    spoilersAlwaysOn: PropTypes.bool,
+    mediaDescriptionConfirmation: PropTypes.bool,
+    preselectOnReply: PropTypes.bool,
+    onChangeSpoilerness: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onPaste: PropTypes.func,
+    onMediaDescriptionConfirm: PropTypes.func,
+  };
+
+  static defaultProps = {
+    showSearch: false,
+  };
+
+  handleChange = (e) => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleSubmit = (overriddenVisibility = null) => {
+    const { textarea: { value }, uploadForm } = this;
+    const {
+      onChange,
+      onSubmit,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      media,
+      anyMedia,
+      text,
+      mediaDescriptionConfirmation,
+      onMediaDescriptionConfirm,
+      onChangeVisibility,
+    } = this.props;
+
+    //  If something changes inside the textarea, then we update the
+    //  state before submitting.
+    if (onChange && text !== value) {
+      onChange(value);
+    }
+
+    // Submit disabled:
+    if (isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia)) {
+      return;
+    }
+
+    // Submit unless there are media with missing descriptions
+    if (mediaDescriptionConfirmation && onMediaDescriptionConfirm && media && media.some(item => !item.get('description'))) {
+      const firstWithoutDescription = media.find(item => !item.get('description'));
+      onMediaDescriptionConfirm(this.context.router ? this.context.router.history : null, firstWithoutDescription.get('id'), overriddenVisibility);
+    } else if (onSubmit) {
+      if (onChangeVisibility && overriddenVisibility) {
+        onChangeVisibility(overriddenVisibility);
+      }
+      onSubmit(this.context.router ? this.context.router.history : null);
+    }
+  }
+
+  //  Changes the text value of the spoiler.
+  handleChangeSpoiler = ({ target: { value } }) => {
+    const { onChangeSpoilerText } = this.props;
+    if (onChangeSpoilerText) {
+      onChangeSpoilerText(value);
+    }
+  }
+
+  setRef = c => {
+    this.composeForm = c;
+  };
+
+  //  Inserts an emoji at the caret.
+  handleEmoji = (data) => {
+    const { textarea: { selectionStart } } = this;
+    const { onPickEmoji } = this.props;
+    if (onPickEmoji) {
+      onPickEmoji(selectionStart, data);
+    }
+  }
+
+  //  Handles the secondary submit button.
+  handleSecondarySubmit = () => {
+    const {
+      sideArm,
+    } = this.props;
+    this.handleSubmit(sideArm === 'none' ? null : sideArm);
+  }
+
+  //  Selects a suggestion from the autofill.
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['text']);
+  }
+
+  onSpoilerSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['spoiler_text']);
+  }
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+
+    if (e.keyCode == 13 && e.altKey) {
+      this.handleSecondarySubmit();
+    }
+  }
+
+  //  Sets a reference to the textarea.
+  setAutosuggestTextarea = (textareaComponent) => {
+    if (textareaComponent) {
+      this.textarea = textareaComponent.textarea;
+    }
+  }
+
+  //  Sets a reference to the CW field.
+  handleRefSpoilerText = (spoilerComponent) => {
+    if (spoilerComponent) {
+      this.spoilerText = spoilerComponent.input;
+    }
+  }
+
+  handleFocus = () => {
+    if (this.composeForm && !this.props.singleColumn) {
+      const { left, right } = this.composeForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.composeForm.scrollIntoView();
+      }
+    }
+  }
+
+  //  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.
+  componentDidUpdate (prevProps) {
+    const {
+      textarea,
+      spoilerText,
+    } = this;
+    const {
+      focusDate,
+      caretPosition,
+      isSubmitting,
+      preselectDate,
+      text,
+      preselectOnReply,
+      singleColumn,
+    } = this.props;
+    let selectionEnd, selectionStart;
+
+    //  Caret/selection handling.
+    if (focusDate !== prevProps.focusDate) {
+      switch (true) {
+      case preselectDate !== prevProps.preselectDate && preselectOnReply:
+        selectionStart = text.search(/\s/) + 1;
+        selectionEnd = text.length;
+        break;
+      case !isNaN(caretPosition) && caretPosition !== null:
+        selectionStart = selectionEnd = caretPosition;
+        break;
+      default:
+        selectionStart = selectionEnd = text.length;
+      }
+      if (textarea) {
+        textarea.setSelectionRange(selectionStart, selectionEnd);
+        textarea.focus();
+        if (!singleColumn) textarea.scrollIntoView();
+      }
+
+    //  Refocuses the textarea after submitting.
+    } else if (textarea && prevProps.isSubmitting && !isSubmitting) {
+      textarea.focus();
+    } else if (this.props.spoiler !== prevProps.spoiler) {
+      if (this.props.spoiler) {
+        if (spoilerText) {
+          spoilerText.focus();
+        }
+      } else {
+        if (textarea) {
+          textarea.focus();
+        }
+      }
+    }
+  }
+
+
+  render () {
+    const {
+      handleEmoji,
+      handleSecondarySubmit,
+      handleSelect,
+      handleSubmit,
+      handleRefTextarea,
+    } = this;
+    const {
+      advancedOptions,
+      anyMedia,
+      intl,
+      isSubmitting,
+      isChangingUpload,
+      isUploading,
+      layout,
+      media,
+      onChangeSpoilerness,
+      onChangeVisibility,
+      onClearSuggestions,
+      onFetchSuggestions,
+      onPaste,
+      privacy,
+      sensitive,
+      showSearch,
+      sideArm,
+      spoiler,
+      spoilerText,
+      suggestions,
+      text,
+      spoilersAlwaysOn,
+    } = this.props;
+
+    let disabledButton = isSubmitting || isUploading || isChangingUpload || (!text.trim().length && !anyMedia);
+
+    const countText = `${spoilerText}${countableText(text)}${advancedOptions && advancedOptions.get('do_not_federate') ? ' 👁️' : ''}`;
+
+    return (
+      <div className='composer'>
+        <WarningContainer />
+
+        <ReplyIndicatorContainer />
+
+        <div className={`composer--spoiler ${spoiler ? 'composer--spoiler--visible' : ''}`} ref={this.setRef}>
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.spoiler_placeholder)}
+            value={spoilerText}
+            onChange={this.handleChangeSpoiler}
+            onKeyDown={this.handleKeyDown}
+            disabled={!spoiler}
+            ref={this.handleRefSpoilerText}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={onFetchSuggestions}
+            onSuggestionsClearRequested={onClearSuggestions}
+            onSuggestionSelected={this.onSpoilerSuggestionSelected}
+            searchTokens={[':']}
+            id='glitch.composer.spoiler.input'
+            className='spoiler-input__input'
+            autoFocus={false}
+          />
+        </div>
+
+        <AutosuggestTextarea
+          ref={this.setAutosuggestTextarea}
+          placeholder={intl.formatMessage(messages.placeholder)}
+          disabled={isSubmitting}
+          value={this.props.text}
+          onChange={this.handleChange}
+          onKeyDown={this.handleKeyDown}
+          suggestions={this.props.suggestions}
+          onFocus={this.handleFocus}
+          onSuggestionsFetchRequested={onFetchSuggestions}
+          onSuggestionsClearRequested={onClearSuggestions}
+          onSuggestionSelected={this.onSuggestionSelected}
+          onPaste={onPaste}
+          autoFocus={!showSearch && !isMobile(window.innerWidth, layout)}
+        >
+          <EmojiPicker onPickEmoji={handleEmoji} />
+          <TextareaIcons advancedOptions={advancedOptions} />
+          <div className='compose-form__modifiers'>
+            <UploadFormContainer />
+            <PollFormContainer />
+          </div>
+        </AutosuggestTextarea>
+
+        <div className='composer--options-wrapper'>
+          <OptionsContainer
+            advancedOptions={advancedOptions}
+            disabled={isSubmitting}
+            onChangeVisibility={onChangeVisibility}
+            onToggleSpoiler={spoilersAlwaysOn ? null : onChangeSpoilerness}
+            onUpload={onPaste}
+            privacy={privacy}
+            sensitive={sensitive || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0)}
+            spoiler={spoilersAlwaysOn ? (spoilerText && spoilerText.length > 0) : spoiler}
+          />
+          <div className='compose--counter-wrapper'>
+            <CharacterCounter text={countText} max={maxChars} />
+          </div>
+        </div>
+
+        <Publisher
+          countText={countText}
+          disabled={disabledButton}
+          onSecondarySubmit={handleSecondarySubmit}
+          onSubmit={handleSubmit}
+          privacy={privacy}
+          sideArm={sideArm}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
new file mode 100644
index 000000000..60035b705
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js
@@ -0,0 +1,235 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import DropdownMenu from './dropdown_menu';
+
+//  Utils.
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  The component.
+export default class ComposerOptionsDropdown extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    icon: PropTypes.string,
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })).isRequired,
+    onModalOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    title: PropTypes.string,
+    value: PropTypes.string,
+    onChange: PropTypes.func,
+  };
+
+  state = {
+    needsModalUpdate: false,
+    open: false,
+    openedViaKeyboard: undefined,
+    placement: 'bottom',
+  };
+
+  //  Toggles opening and closing the dropdown.
+  handleToggle = ({ target, type }) => {
+    const { onModalOpen } = this.props;
+    const { open } = this.state;
+
+    if (isUserTouching()) {
+      if (this.state.open) {
+        this.props.onModalClose();
+      } else {
+        const modal = this.handleMakeModal();
+        if (modal && onModalOpen) {
+          onModalOpen(modal);
+        }
+      }
+    } else {
+      const { top } = target.getBoundingClientRect();
+      if (this.state.open && this.activeElement) {
+        this.activeElement.focus();
+      }
+      this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+      this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' });
+    }
+  }
+
+  handleKeyDown = (e) => {
+    switch (e.key) {
+    case 'Escape':
+      this.handleClose();
+      break;
+    }
+  }
+
+  handleMouseDown = () => {
+    if (!this.state.open) {
+      this.activeElement = document.activeElement;
+    }
+  }
+
+  handleButtonKeyDown = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleMouseDown();
+      break;
+    }
+  }
+
+  handleKeyPress = (e) => {
+    switch(e.key) {
+    case ' ':
+    case 'Enter':
+      this.handleToggle(e);
+      e.stopPropagation();
+      e.preventDefault();
+      break;
+    }
+  }
+
+  handleClose = () => {
+    if (this.state.open && this.activeElement) {
+      this.activeElement.focus();
+    }
+    this.setState({ open: false });
+  }
+
+  //  Creates an action modal object.
+  handleMakeModal = () => {
+    const component = this;
+    const {
+      items,
+      onChange,
+      onModalOpen,
+      onModalClose,
+      value,
+    } = this.props;
+
+    //  Required props.
+    if (!(onChange && onModalOpen && onModalClose && items)) {
+      return null;
+    }
+
+    //  The object.
+    return {
+      actions: items.map(
+        ({
+          name,
+          ...rest
+        }) => ({
+          ...rest,
+          active: value && name === value,
+          name,
+          onClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onModalClose();
+            onChange(name);
+          },
+          onPassiveClick (e) {
+            e.preventDefault();  //  Prevents focus from changing
+            onChange(name);
+            component.setState({ needsModalUpdate: true });
+          },
+        })
+      ),
+    };
+  }
+
+  //  If our modal is open and our props update, we need to also update
+  //  the modal.
+  handleUpdate = () => {
+    const { onModalOpen } = this.props;
+    const { needsModalUpdate } = this.state;
+
+    //  Gets our modal object.
+    const modal = this.handleMakeModal();
+
+    //  Reopens the modal with the new object.
+    if (needsModalUpdate && modal && onModalOpen) {
+      onModalOpen(modal);
+    }
+  }
+
+  //  Updates our modal as necessary.
+  componentDidUpdate (prevProps) {
+    const { items } = this.props;
+    const { needsModalUpdate } = this.state;
+    if (needsModalUpdate && items.find(
+      (item, i) => item.on !== prevProps.items[i].on
+    )) {
+      this.handleUpdate();
+      this.setState({ needsModalUpdate: false });
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      active,
+      disabled,
+      title,
+      icon,
+      items,
+      onChange,
+      value,
+    } = this.props;
+    const { open, placement } = this.state;
+    const computedClass = classNames('composer--options--dropdown', {
+      active,
+      open,
+      top: placement === 'top',
+    });
+
+    //  The result.
+    return (
+      <div
+        className={computedClass}
+        onKeyDown={this.handleKeyDown}
+      >
+        <IconButton
+          active={open || active}
+          className='value'
+          disabled={disabled}
+          icon={icon}
+          inverted
+          onClick={this.handleToggle}
+          onMouseDown={this.handleMouseDown}
+          onKeyDown={this.handleButtonKeyDown}
+          onKeyPress={this.handleKeyPress}
+          size={18}
+          style={{
+            height: null,
+            lineHeight: '27px',
+          }}
+          title={title}
+        />
+        <Overlay
+          containerPadding={20}
+          placement={placement}
+          show={open}
+          target={this}
+        >
+          <DropdownMenu
+            items={items}
+            onChange={onChange}
+            onClose={this.handleClose}
+            value={value}
+            openedViaKeyboard={this.state.openedViaKeyboard}
+          />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
new file mode 100644
index 000000000..bee06e64c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js
@@ -0,0 +1,239 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import spring from 'react-motion/lib/spring';
+import Toggle from 'react-toggle';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import classNames from 'classnames';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { withPassive } from 'flavours/glitch/util/dom_helpers';
+import Motion from 'flavours/glitch/util/optional_motion';
+import { assignHandlers } from 'flavours/glitch/util/react_helpers';
+
+//  The spring to use with our motion.
+const springMotion = spring(1, {
+  damping: 35,
+  stiffness: 400,
+});
+
+//  The component.
+export default class ComposerOptionsDropdownContent extends React.PureComponent {
+
+  static propTypes = {
+    items: PropTypes.arrayOf(PropTypes.shape({
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string.isRequired,
+      on: PropTypes.bool,
+      text: PropTypes.node,
+    })),
+    onChange: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    style: PropTypes.object,
+    value: PropTypes.string,
+    openedViaKeyboard: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    style: {},
+  };
+
+  state = {
+    mounted: false,
+    value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined,
+  };
+
+  //  When the document is clicked elsewhere, we close the dropdown.
+  handleDocumentClick = (e) => {
+    if (this.node && !this.node.contains(e.target)) {
+      this.props.onClose();
+    }
+  }
+
+  //  Stores our node in `this.node`.
+  handleRef = (node) => {
+    this.node = node;
+  }
+
+  //  On mounting, we add our listeners.
+  componentDidMount () {
+    document.addEventListener('click', this.handleDocumentClick, false);
+    document.addEventListener('touchend', this.handleDocumentClick, withPassive);
+    if (this.focusedItem) {
+      this.focusedItem.focus({ preventScroll: true });
+    } else {
+      this.node.firstChild.focus({ preventScroll: true });
+    }
+    this.setState({ mounted: true });
+  }
+
+  //  On unmounting, we remove our listeners.
+  componentWillUnmount () {
+    document.removeEventListener('click', this.handleDocumentClick, false);
+    document.removeEventListener('touchend', this.handleDocumentClick, withPassive);
+  }
+
+  handleClick = (name, e) => {
+    const {
+      onChange,
+      onClose,
+      items,
+    } = this.props;
+
+    const { on } = this.props.items.find(item => item.name === name);
+    e.preventDefault();  //  Prevents change in focus on click
+    if ((on === null || typeof on === 'undefined')) {
+      onClose();
+    }
+    onChange(name);
+  }
+
+  // Handle changes differently whether the dropdown is a list of options or actions
+  handleChange = (name) => {
+    if (this.props.value) {
+      this.props.onChange(name);
+    } else {
+      this.setState({ value: name });
+    }
+  }
+
+  handleKeyDown = (name, e) => {
+    const { items } = this.props;
+    const index = items.findIndex(item => {
+      return (item.name === name);
+    });
+    let element = null;
+
+    switch(e.key) {
+    case 'Escape':
+      this.props.onClose();
+      break;
+    case 'Enter':
+    case ' ':
+      this.handleClick(e);
+      break;
+    case 'ArrowDown':
+      element = this.node.childNodes[index + 1] || this.node.firstChild;
+      break;
+    case 'ArrowUp':
+      element = this.node.childNodes[index - 1] || this.node.lastChild;
+      break;
+    case 'Tab':
+      if (e.shiftKey) {
+        element = this.node.childNodes[index - 1] || this.node.lastChild;
+      } else {
+        element = this.node.childNodes[index + 1] || this.node.firstChild;
+      }
+      break;
+    case 'Home':
+      element = this.node.firstChild;
+      break;
+    case 'End':
+      element = this.node.lastChild;
+      break;
+    }
+
+    if (element) {
+      element.focus();
+      this.handleChange(element.getAttribute('data-index'));
+      e.preventDefault();
+      e.stopPropagation();
+    }
+  }
+
+  setFocusRef = c => {
+    this.focusedItem = c;
+  }
+
+  renderItem = (item) => {
+    const { name, icon, meta, on, text } = item;
+
+    const active = (name === (this.props.value || this.state.value));
+
+    const computedClass = classNames('composer--options--dropdown--content--item', {
+      active,
+      lengthy: meta,
+      'toggled-off': !on && on !== null && typeof on !== 'undefined',
+      'toggled-on': on,
+      'with-icon': icon,
+    });
+
+    let prefix = null;
+
+    if (on !== null && typeof on !== 'undefined') {
+      prefix = <Toggle checked={on} onChange={this.handleClick.bind(this, name)} />;
+    } else if (icon) {
+      prefix = <Icon className='icon' fixedWidth id={icon} />
+    }
+
+    return (
+      <div
+        className={computedClass}
+        onClick={this.handleClick.bind(this, name)}
+        onKeyDown={this.handleKeyDown.bind(this, name)}
+        role='option'
+        tabIndex='0'
+        key={name}
+        data-index={name}
+        ref={active ? this.setFocusRef : null}
+      >
+        {prefix}
+
+        <div className='content'>
+          <strong>{text}</strong>
+          {meta}
+        </div>
+      </div>
+    );
+  }
+
+  //  Rendering.
+  render () {
+    const { mounted } = this.state;
+    const {
+      items,
+      onChange,
+      onClose,
+      style,
+    } = this.props;
+
+    //  The result.
+    return (
+      <Motion
+        defaultStyle={{
+          opacity: 0,
+          scaleX: 0.85,
+          scaleY: 0.75,
+        }}
+        style={{
+          opacity: springMotion,
+          scaleX: springMotion,
+          scaleY: springMotion,
+        }}
+      >
+        {({ opacity, scaleX, scaleY }) => (
+          // It should not be transformed when mounting because the resulting
+          // size will be used to determine the coordinate of the menu by
+          // react-overlays
+          <div
+            className='composer--options--dropdown--content'
+            ref={this.handleRef}
+            role='listbox'
+            style={{
+              ...style,
+              opacity: opacity,
+              transform: mounted ? `scale(${scaleX}, ${scaleY})` : null,
+            }}
+          >
+            {!!items && items.map(item => this.renderItem(item))}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/header.js b/app/javascript/flavours/glitch/features/compose/components/header.js
new file mode 100644
index 000000000..5b456b717
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/header.js
@@ -0,0 +1,134 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, defineMessages } from 'react-intl';
+import { Link } from 'react-router-dom';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { conditionalRender } from 'flavours/glitch/util/react_helpers';
+import { signOutLink } from 'flavours/glitch/util/backend_links';
+
+//  Messages.
+const messages = defineMessages({
+  community: {
+    defaultMessage: 'Local timeline',
+    id: 'navigation_bar.community_timeline',
+  },
+  home_timeline: {
+    defaultMessage: 'Home',
+    id: 'tabs_bar.home',
+  },
+  logout: {
+    defaultMessage: 'Logout',
+    id: 'navigation_bar.logout',
+  },
+  notifications: {
+    defaultMessage: 'Notifications',
+    id: 'tabs_bar.notifications',
+  },
+  public: {
+    defaultMessage: 'Federated timeline',
+    id: 'navigation_bar.public_timeline',
+  },
+  settings: {
+    defaultMessage: 'App settings',
+    id: 'navigation_bar.app_settings',
+  },
+  start: {
+    defaultMessage: 'Getting started',
+    id: 'getting_started.heading',
+  },
+});
+
+export default @injectIntl
+class Header extends ImmutablePureComponent {
+  static propTypes = {
+    columns: ImmutablePropTypes.list,
+    unreadNotifications: PropTypes.number,
+    showNotificationsBadge: PropTypes.bool,
+    intl: PropTypes.object,
+    onSettingsClick: PropTypes.func,
+    onLogout: PropTypes.func.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+
+    return false;
+  }
+
+  render () {
+    const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
+
+    //  Only renders the component if the column isn't being shown.
+    const renderForColumn = conditionalRender.bind(null,
+      columnId => !columns || !columns.some(
+        column => column.get('id') === columnId
+      )
+    );
+
+    //  The result.
+    return (
+      <nav className='drawer--header'>
+        <Link
+          aria-label={intl.formatMessage(messages.start)}
+          title={intl.formatMessage(messages.start)}
+          to='/getting-started'
+        ><Icon id='asterisk' /></Link>
+        {renderForColumn('HOME', (
+          <Link
+            aria-label={intl.formatMessage(messages.home_timeline)}
+            title={intl.formatMessage(messages.home_timeline)}
+            to='/timelines/home'
+          ><Icon id='home' /></Link>
+        ))}
+        {renderForColumn('NOTIFICATIONS', (
+          <Link
+            aria-label={intl.formatMessage(messages.notifications)}
+            title={intl.formatMessage(messages.notifications)}
+            to='/notifications'
+          >
+            <span className='icon-badge-wrapper'>
+              <Icon id='bell' />
+              { showNotificationsBadge && unreadNotifications > 0 && <div className='icon-badge' />}
+            </span>
+          </Link>
+        ))}
+        {renderForColumn('COMMUNITY', (
+          <Link
+            aria-label={intl.formatMessage(messages.community)}
+            title={intl.formatMessage(messages.community)}
+            to='/timelines/public/local'
+          ><Icon id='users' /></Link>
+        ))}
+        {renderForColumn('PUBLIC', (
+          <Link
+            aria-label={intl.formatMessage(messages.public)}
+            title={intl.formatMessage(messages.public)}
+            to='/timelines/public'
+          ><Icon id='globe' /></Link>
+        ))}
+        <a
+          aria-label={intl.formatMessage(messages.settings)}
+          onClick={onSettingsClick}
+          href='#'
+          title={intl.formatMessage(messages.settings)}
+        ><Icon id='cogs' /></a>
+        <a
+          aria-label={intl.formatMessage(messages.logout)}
+          onClick={this.handleLogoutClick}
+          href={ signOutLink }
+          title={intl.formatMessage(messages.logout)}
+        ><Icon id='sign-out' /></a>
+      </nav>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
new file mode 100644
index 000000000..f6bfbdd1e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/navigation_bar.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import Permalink from 'flavours/glitch/components/permalink';
+import { FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { profileLink } from 'flavours/glitch/util/backend_links';
+
+export default class NavigationBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    return (
+      <div className='drawer--account'>
+        <Permalink className='avatar' 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={48} />
+        </Permalink>
+
+        <div className='navigation-bar__profile'>
+          <Permalink className='acct' href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
+            <strong>@{this.props.account.get('acct')}</strong>
+          </Permalink>
+
+          { profileLink !== undefined && (
+            <a
+              className='edit'
+              href={ profileLink }
+            ><FormattedMessage id='navigation_bar.edit_profile' defaultMessage='Edit profile' /></a>
+          )}
+        </div>
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/options.js b/app/javascript/flavours/glitch/features/compose/components/options.js
new file mode 100644
index 000000000..9e332aabd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/options.js
@@ -0,0 +1,367 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import spring from 'react-motion/lib/spring';
+
+//  Components.
+import IconButton from 'flavours/glitch/components/icon_button';
+import TextIconButton from './text_icon_button';
+import Dropdown from './dropdown';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Utils.
+import Motion from 'flavours/glitch/util/optional_motion';
+import { pollLimits } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  advanced_options_icon_title: {
+    defaultMessage: 'Advanced options',
+    id: 'advanced_options.icon_title',
+  },
+  attach: {
+    defaultMessage: 'Attach...',
+    id: 'compose.attach',
+  },
+  change_privacy: {
+    defaultMessage: 'Adjust status privacy',
+    id: 'privacy.change',
+  },
+  content_type: {
+    defaultMessage: 'Content type',
+    id: 'content-type.change',
+  },
+  direct_long: {
+    defaultMessage: 'Visible for mentioned users only',
+    id: 'privacy.direct.long',
+  },
+  direct_short: {
+    defaultMessage: 'Direct',
+    id: 'privacy.direct.short',
+  },
+  doodle: {
+    defaultMessage: 'Draw something',
+    id: 'compose.attach.doodle',
+  },
+  html: {
+    defaultMessage: 'HTML',
+    id: 'compose.content-type.html',
+  },
+  local_only_long: {
+    defaultMessage: 'Do not post to other instances',
+    id: 'advanced_options.local-only.long',
+  },
+  local_only_short: {
+    defaultMessage: 'Local-only',
+    id: 'advanced_options.local-only.short',
+  },
+  markdown: {
+    defaultMessage: 'Markdown',
+    id: 'compose.content-type.markdown',
+  },
+  plain: {
+    defaultMessage: 'Plain text',
+    id: 'compose.content-type.plain',
+  },
+  private_long: {
+    defaultMessage: 'Visible for followers only',
+    id: 'privacy.private.long',
+  },
+  private_short: {
+    defaultMessage: 'Followers-only',
+    id: 'privacy.private.short',
+  },
+  public_long: {
+    defaultMessage: 'Visible for all, shown in public timelines',
+    id: 'privacy.public.long',
+  },
+  public_short: {
+    defaultMessage: 'Public',
+    id: 'privacy.public.short',
+  },
+  spoiler: {
+    defaultMessage: 'Hide text behind warning',
+    id: 'compose_form.spoiler',
+  },
+  threaded_mode_long: {
+    defaultMessage: 'Automatically opens a reply on posting',
+    id: 'advanced_options.threaded_mode.long',
+  },
+  threaded_mode_short: {
+    defaultMessage: 'Threaded mode',
+    id: 'advanced_options.threaded_mode.short',
+  },
+  unlisted_long: {
+    defaultMessage: 'Visible for all, but not in public timelines',
+    id: 'privacy.unlisted.long',
+  },
+  unlisted_short: {
+    defaultMessage: 'Unlisted',
+    id: 'privacy.unlisted.short',
+  },
+  upload: {
+    defaultMessage: 'Upload a file',
+    id: 'compose.attach.upload',
+  },
+  add_poll: {
+    defaultMessage: 'Add a poll',
+    id: 'poll_button.add_poll',
+  },
+  remove_poll: {
+    defaultMessage: 'Remove poll',
+    id: 'poll_button.remove_poll',
+  },
+});
+
+export default @injectIntl
+class ComposerOptions extends ImmutablePureComponent {
+
+  static propTypes = {
+    acceptContentTypes: PropTypes.string,
+    advancedOptions: ImmutablePropTypes.map,
+    disabled: PropTypes.bool,
+    allowMedia: PropTypes.bool,
+    hasMedia: PropTypes.bool,
+    allowPoll: PropTypes.bool,
+    hasPoll: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChangeAdvancedOption: PropTypes.func,
+    onChangeVisibility: PropTypes.func,
+    onChangeContentType: PropTypes.func,
+    onTogglePoll: PropTypes.func,
+    onDoodleOpen: PropTypes.func,
+    onModalClose: PropTypes.func,
+    onModalOpen: PropTypes.func,
+    onToggleSpoiler: PropTypes.func,
+    onUpload: PropTypes.func,
+    privacy: PropTypes.string,
+    contentType: PropTypes.string,
+    resetFileKey: PropTypes.number,
+    spoiler: PropTypes.bool,
+    showContentTypeChoice: PropTypes.bool,
+  };
+
+  //  Handles file selection.
+  handleChangeFiles = ({ target: { files } }) => {
+    const { onUpload } = this.props;
+    if (files.length && onUpload) {
+      onUpload(files);
+    }
+  }
+
+  //  Handles attachment clicks.
+  handleClickAttach = (name) => {
+    const { fileElement } = this;
+    const { onDoodleOpen } = this.props;
+
+    //  We switch over the name of the option.
+    switch (name) {
+    case 'upload':
+      if (fileElement) {
+        fileElement.click();
+      }
+      return;
+    case 'doodle':
+      if (onDoodleOpen) {
+        onDoodleOpen();
+      }
+      return;
+    }
+  }
+
+  //  Handles a ref to the file input.
+  handleRefFileElement = (fileElement) => {
+    this.fileElement = fileElement;
+  }
+
+  //  Rendering.
+  render () {
+    const {
+      acceptContentTypes,
+      advancedOptions,
+      contentType,
+      disabled,
+      allowMedia,
+      hasMedia,
+      allowPoll,
+      hasPoll,
+      intl,
+      onChangeAdvancedOption,
+      onChangeContentType,
+      onChangeVisibility,
+      onTogglePoll,
+      onModalClose,
+      onModalOpen,
+      onToggleSpoiler,
+      privacy,
+      resetFileKey,
+      spoiler,
+      showContentTypeChoice,
+    } = this.props;
+
+    //  We predefine our privacy items so that we can easily pick the
+    //  dropdown icon later.
+    const privacyItems = {
+      direct: {
+        icon: 'envelope',
+        meta: <FormattedMessage {...messages.direct_long} />,
+        name: 'direct',
+        text: <FormattedMessage {...messages.direct_short} />,
+      },
+      private: {
+        icon: 'lock',
+        meta: <FormattedMessage {...messages.private_long} />,
+        name: 'private',
+        text: <FormattedMessage {...messages.private_short} />,
+      },
+      public: {
+        icon: 'globe',
+        meta: <FormattedMessage {...messages.public_long} />,
+        name: 'public',
+        text: <FormattedMessage {...messages.public_short} />,
+      },
+      unlisted: {
+        icon: 'unlock',
+        meta: <FormattedMessage {...messages.unlisted_long} />,
+        name: 'unlisted',
+        text: <FormattedMessage {...messages.unlisted_short} />,
+      },
+    };
+
+    const contentTypeItems = {
+      plain: {
+        icon: 'file-text',
+        name: 'text/plain',
+        text: <FormattedMessage {...messages.plain} />,
+      },
+      html: {
+        icon: 'code',
+        name: 'text/html',
+        text: <FormattedMessage {...messages.html} />,
+      },
+      markdown: {
+        icon: 'arrow-circle-down',
+        name: 'text/markdown',
+        text: <FormattedMessage {...messages.markdown} />,
+      },
+    };
+
+    //  The result.
+    return (
+      <div className='composer--options'>
+        <input
+          accept={acceptContentTypes}
+          disabled={disabled || !allowMedia}
+          key={resetFileKey}
+          onChange={this.handleChangeFiles}
+          ref={this.handleRefFileElement}
+          type='file'
+          multiple
+          style={{ display: 'none' }}
+        />
+        <Dropdown
+          disabled={disabled || !allowMedia}
+          icon='paperclip'
+          items={[
+            {
+              icon: 'cloud-upload',
+              name: 'upload',
+              text: <FormattedMessage {...messages.upload} />,
+            },
+            {
+              icon: 'paint-brush',
+              name: 'doodle',
+              text: <FormattedMessage {...messages.doodle} />,
+            },
+          ]}
+          onChange={this.handleClickAttach}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.attach)}
+        />
+        {!!pollLimits && (
+          <IconButton
+            active={hasPoll}
+            disabled={disabled || !allowPoll}
+            icon='tasks'
+            inverted
+            onClick={onTogglePoll}
+            size={18}
+            style={{
+              height: null,
+              lineHeight: null,
+            }}
+            title={intl.formatMessage(hasPoll ? messages.remove_poll : messages.add_poll)}
+          />
+        )}
+        <hr />
+        <Dropdown
+          disabled={disabled}
+          icon={(privacyItems[privacy] || {}).icon}
+          items={[
+            privacyItems.public,
+            privacyItems.unlisted,
+            privacyItems.private,
+            privacyItems.direct,
+          ]}
+          onChange={onChangeVisibility}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.change_privacy)}
+          value={privacy}
+        />
+        {showContentTypeChoice && (
+          <Dropdown
+            disabled={disabled}
+            icon={(contentTypeItems[contentType.split('/')[1]] || {}).icon}
+            items={[
+              contentTypeItems.plain,
+              contentTypeItems.html,
+              contentTypeItems.markdown,
+            ]}
+            onChange={onChangeContentType}
+            onModalClose={onModalClose}
+            onModalOpen={onModalOpen}
+            title={intl.formatMessage(messages.content_type)}
+            value={contentType}
+          />
+        )}
+        {onToggleSpoiler && (
+          <TextIconButton
+            active={spoiler}
+            ariaControls='glitch.composer.spoiler.input'
+            label='CW'
+            onClick={onToggleSpoiler}
+            title={intl.formatMessage(messages.spoiler)}
+          />
+        )}
+        <Dropdown
+          active={advancedOptions && advancedOptions.some(value => !!value)}
+          disabled={disabled}
+          icon='ellipsis-h'
+          items={advancedOptions ? [
+            {
+              meta: <FormattedMessage {...messages.local_only_long} />,
+              name: 'do_not_federate',
+              on: advancedOptions.get('do_not_federate'),
+              text: <FormattedMessage {...messages.local_only_short} />,
+            },
+            {
+              meta: <FormattedMessage {...messages.threaded_mode_long} />,
+              name: 'threaded_mode',
+              on: advancedOptions.get('threaded_mode'),
+              text: <FormattedMessage {...messages.threaded_mode_short} />,
+            },
+          ] : null}
+          onChange={onChangeAdvancedOption}
+          onModalClose={onModalClose}
+          onModalOpen={onModalOpen}
+          title={intl.formatMessage(messages.advanced_options_icon_title)}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/poll_form.js b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
new file mode 100644
index 000000000..e4b5104f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/poll_form.js
@@ -0,0 +1,165 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import AutosuggestInput from 'flavours/glitch/components/autosuggest_input';
+import classNames from 'classnames';
+import { pollLimits } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  option_placeholder: { id: 'compose_form.poll.option_placeholder', defaultMessage: 'Choice {number}' },
+  add_option: { id: 'compose_form.poll.add_option', defaultMessage: 'Add a choice' },
+  remove_option: { id: 'compose_form.poll.remove_option', defaultMessage: 'Remove this choice' },
+  poll_duration: { id: 'compose_form.poll.duration', defaultMessage: 'Poll duration' },
+  single_choice: { id: 'compose_form.poll.single_choice', defaultMessage: 'Allow one choice' },
+  multiple_choices: { id: 'compose_form.poll.multiple_choices', defaultMessage: 'Allow multiple choices' },
+  minutes: { id: 'intervals.full.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}}' },
+  hours: { id: 'intervals.full.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}}' },
+  days: { id: 'intervals.full.days', defaultMessage: '{number, plural, one {# day} other {# days}}' },
+});
+
+@injectIntl
+class Option extends React.PureComponent {
+
+  static propTypes = {
+    title: PropTypes.string.isRequired,
+    index: PropTypes.number.isRequired,
+    isPollMultiple: PropTypes.bool,
+    autoFocus: PropTypes.bool,
+    onChange: PropTypes.func.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleOptionTitleChange = e => {
+    this.props.onChange(this.props.index, e.target.value);
+  };
+
+  handleOptionRemove = () => {
+    this.props.onRemove(this.props.index);
+  };
+
+  onSuggestionsClearRequested = () => {
+    this.props.onClearSuggestions();
+  }
+
+  onSuggestionsFetchRequested = (token) => {
+    this.props.onFetchSuggestions(token);
+  }
+
+  onSuggestionSelected = (tokenStart, token, value) => {
+    this.props.onSuggestionSelected(tokenStart, token, value, ['poll', 'options', this.props.index]);
+  }
+
+  render () {
+    const { isPollMultiple, title, index, autoFocus, intl } = this.props;
+
+    return (
+      <li>
+        <label className='poll__option editable'>
+          <span className={classNames('poll__input', { checkbox: isPollMultiple })} />
+
+          <AutosuggestInput
+            placeholder={intl.formatMessage(messages.option_placeholder, { number: index + 1 })}
+            maxLength={pollLimits.max_option_chars}
+            value={title}
+            onChange={this.handleOptionTitleChange}
+            suggestions={this.props.suggestions}
+            onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
+            onSuggestionsClearRequested={this.onSuggestionsClearRequested}
+            onSuggestionSelected={this.onSuggestionSelected}
+            searchTokens={[':']}
+            autoFocus={autoFocus}
+          />
+        </label>
+
+        <div className='poll__cancel'>
+          <IconButton disabled={index <= 1} title={intl.formatMessage(messages.remove_option)} icon='times' onClick={this.handleOptionRemove} />
+        </div>
+      </li>
+    );
+  }
+
+}
+
+export default
+@injectIntl
+class PollForm extends ImmutablePureComponent {
+
+  static propTypes = {
+    options: ImmutablePropTypes.list,
+    expiresIn: PropTypes.number,
+    isMultiple: PropTypes.bool,
+    onChangeOption: PropTypes.func.isRequired,
+    onAddOption: PropTypes.func.isRequired,
+    onRemoveOption: PropTypes.func.isRequired,
+    onChangeSettings: PropTypes.func.isRequired,
+    suggestions: ImmutablePropTypes.list,
+    onClearSuggestions: PropTypes.func.isRequired,
+    onFetchSuggestions: PropTypes.func.isRequired,
+    onSuggestionSelected: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleAddOption = () => {
+    this.props.onAddOption('');
+  };
+
+  handleSelectDuration = e => {
+    this.props.onChangeSettings(e.target.value, this.props.isMultiple);
+  };
+
+  handleSelectMultiple = e => {
+    this.props.onChangeSettings(this.props.expiresIn, e.target.value === 'true');
+  };
+
+  render () {
+    const { options, expiresIn, isMultiple, onChangeOption, onRemoveOption, intl, ...other } = this.props;
+
+    if (!options) {
+      return null;
+    }
+
+    const autoFocusIndex = options.indexOf('');
+
+    return (
+      <div className='compose-form__poll-wrapper'>
+        <ul>
+          {options.map((title, i) => <Option title={title} key={i} index={i} onChange={onChangeOption} onRemove={onRemoveOption} isPollMultiple={isMultiple} autoFocus={i === autoFocusIndex} {...other} />)}
+          {options.size < pollLimits.max_options && (
+            <label className='poll__text editable'>
+              <span className={classNames('poll__input')} style={{ opacity: 0 }} />
+              <button className='button button-secondary' onClick={this.handleAddOption}><Icon id='plus' /> <FormattedMessage {...messages.add_option} /></button>
+            </label>
+          )}
+        </ul>
+
+        <div className='poll__footer'>
+          <select value={isMultiple ? 'true' : 'false'} onChange={this.handleSelectMultiple}>
+            <option value='false'>{intl.formatMessage(messages.single_choice)}</option>
+            <option value='true'>{intl.formatMessage(messages.multiple_choices)}</option>
+          </select>
+
+          {/* eslint-disable-next-line jsx-a11y/no-onchange */}
+          <select value={expiresIn} onChange={this.handleSelectDuration}>
+            <option value={300}>{intl.formatMessage(messages.minutes, { number: 5 })}</option>
+            <option value={1800}>{intl.formatMessage(messages.minutes, { number: 30 })}</option>
+            <option value={3600}>{intl.formatMessage(messages.hours, { number: 1 })}</option>
+            <option value={21600}>{intl.formatMessage(messages.hours, { number: 6 })}</option>
+            <option value={86400}>{intl.formatMessage(messages.days, { number: 1 })}</option>
+            <option value={259200}>{intl.formatMessage(messages.days, { number: 3 })}</option>
+            <option value={604800}>{intl.formatMessage(messages.days, { number: 7 })}</option>
+          </select>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/publisher.js b/app/javascript/flavours/glitch/features/compose/components/publisher.js
new file mode 100644
index 000000000..97890f40d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/publisher.js
@@ -0,0 +1,118 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import { length } from 'stringz';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Button from 'flavours/glitch/components/button';
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { maxChars } from 'flavours/glitch/util/initial_state';
+
+//  Messages.
+const messages = defineMessages({
+  publish: {
+    defaultMessage: 'Toot',
+    id: 'compose_form.publish',
+  },
+  publishLoud: {
+    defaultMessage: '{publish}!',
+    id: 'compose_form.publish_loud',
+  },
+});
+
+export default @injectIntl
+class Publisher extends ImmutablePureComponent {
+
+  static propTypes = {
+    countText: PropTypes.string,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onSecondarySubmit: PropTypes.func,
+    onSubmit: PropTypes.func,
+    privacy: PropTypes.oneOf(['direct', 'private', 'unlisted', 'public']),
+    sideArm: PropTypes.oneOf(['none', 'direct', 'private', 'unlisted', 'public']),
+  };
+
+  handleSubmit = () => {
+    this.props.onSubmit();
+  };
+
+  render () {
+    const { countText, disabled, intl, onSecondarySubmit, privacy, sideArm } = this.props;
+
+    const diff = maxChars - length(countText || '');
+    const computedClass = classNames('composer--publisher', {
+      disabled: disabled || diff < 0,
+      over: diff < 0,
+    });
+
+    return (
+      <div className={computedClass}>
+        {sideArm && sideArm !== 'none' ? (
+          <Button
+            className='side_arm'
+            disabled={disabled || diff < 0}
+            onClick={onSecondarySubmit}
+            style={{ padding: null }}
+            text={
+              <span>
+                <Icon
+                  id={{
+                    public: 'globe',
+                    unlisted: 'unlock',
+                    private: 'lock',
+                    direct: 'envelope',
+                  }[sideArm]}
+                />
+              </span>
+            }
+            title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${sideArm}.short` })}`}
+          />
+        ) : null}
+        <Button
+          className='primary'
+          text={function () {
+            switch (true) {
+            case !!sideArm && sideArm !== 'none':
+            case privacy === 'direct':
+            case privacy === 'private':
+              return (
+                <span>
+                  <Icon
+                    id={{
+                      direct: 'envelope',
+                      private: 'lock',
+                      public: 'globe',
+                      unlisted: 'unlock',
+                    }[privacy]}
+                  />
+                  {' '}
+                  <FormattedMessage {...messages.publish} />
+                </span>
+              );
+            case privacy === 'public':
+              return (
+                <span>
+                  <FormattedMessage
+                    {...messages.publishLoud}
+                    values={{ publish: <FormattedMessage {...messages.publish} /> }}
+                  />
+                </span>
+              );
+            default:
+              return <span><FormattedMessage {...messages.publish} /></span>;
+            }
+          }()}
+          title={`${intl.formatMessage(messages.publish)}: ${intl.formatMessage({ id: `privacy.${privacy}.short` })}`}
+          onClick={this.handleSubmit}
+          disabled={disabled || diff < 0}
+        />
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
new file mode 100644
index 000000000..9d5b65a40
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/reply_indicator.js
@@ -0,0 +1,86 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import IconButton from 'flavours/glitch/components/icon_button';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+
+//  Utils.
+import { isRtl } from 'flavours/glitch/util/rtl';
+
+//  Messages.
+const messages = defineMessages({
+  cancel: {
+    defaultMessage: 'Cancel',
+    id: 'reply_indicator.cancel',
+  },
+});
+
+
+export default @injectIntl
+class ReplyIndicator extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+    onCancel: PropTypes.func,
+  };
+
+  handleClick = () => {
+    const { onCancel } = this.props;
+    if (onCancel) {
+      onCancel();
+    }
+  }
+
+  //  Rendering.
+  render () {
+    const { status, intl } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    const account     = status.get('account');
+    const content     = status.get('content');
+    const attachments = status.get('media_attachments');
+
+    //  The result.
+    return (
+      <article className='composer--reply'>
+        <header>
+          <IconButton
+            className='cancel'
+            icon='times'
+            onClick={this.handleClick}
+            title={intl.formatMessage(messages.cancel)}
+            inverted
+          />
+          {account && (
+            <AccountContainer
+              id={account}
+              small
+            />
+          )}
+        </header>
+        <div
+          className='content'
+          dangerouslySetInnerHTML={{ __html: content || '' }}
+          style={{ direction: isRtl(content) ? 'rtl' : 'ltr' }}
+        />
+        {attachments.size > 0 && (
+          <AttachmentList
+            compact
+            media={attachments}
+          />
+        )}
+      </article>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search.js b/app/javascript/flavours/glitch/features/compose/components/search.js
new file mode 100644
index 000000000..12d839637
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search.js
@@ -0,0 +1,177 @@
+//  Package imports.
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { connect } from 'react-redux';
+import spring from 'react-motion/lib/spring';
+import {
+  injectIntl,
+  FormattedMessage,
+  defineMessages,
+} from 'react-intl';
+import Overlay from 'react-overlays/lib/Overlay';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Utils.
+import { focusRoot } from 'flavours/glitch/util/dom_helpers';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import Motion from 'flavours/glitch/util/optional_motion';
+
+const messages = defineMessages({
+  placeholder: { id: 'search.placeholder', defaultMessage: 'Search' },
+});
+
+class SearchPopout extends React.PureComponent {
+
+  static propTypes = {
+    style: PropTypes.object,
+  };
+
+  render () {
+    const { style } = this.props;
+    const extraInformation = searchEnabled ? <FormattedMessage id='search_popout.tips.full_text' defaultMessage='Simple text returns statuses you have written, favourited, boosted, or have been mentioned in, as well as matching usernames, display names, and hashtags.' /> : <FormattedMessage id='search_popout.tips.text' defaultMessage='Simple text returns matching display names, usernames and hashtags' />;
+    return (
+      <div style={{ ...style, position: 'absolute', width: 285, zIndex: 2 }}>
+        <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>
+
+              {extraInformation}
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
+
+//  The component.
+export default @injectIntl
+class Search extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  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,
+    openInRoute: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    singleColumn: PropTypes.bool,
+  };
+
+  state = {
+    expanded: false,
+  };
+
+  setRef = c => {
+    this.searchForm = c;
+  }
+
+  handleChange = (e) => {
+    const { onChange } = this.props;
+    if (onChange) {
+      onChange(e.target.value);
+    }
+  }
+
+  handleClear = (e) => {
+    const {
+      onClear,
+      submitted,
+      value,
+    } = this.props;
+    e.preventDefault();  //  Prevents focus change ??
+    if (onClear && (submitted || value && value.length)) {
+      onClear();
+    }
+  }
+
+  handleBlur = () => {
+    this.setState({ expanded: false });
+  }
+
+  handleFocus = () => {
+    this.setState({ expanded: true });
+    this.props.onShow();
+
+    if (this.searchForm && !this.props.singleColumn) {
+      const { left, right } = this.searchForm.getBoundingClientRect();
+      if (left < 0 || right > (window.innerWidth || document.documentElement.clientWidth)) {
+        this.searchForm.scrollIntoView();
+      }
+    }
+  }
+
+  handleKeyUp = (e) => {
+    const { onSubmit } = this.props;
+    switch (e.key) {
+    case 'Enter':
+      onSubmit();
+
+      if (this.props.openInRoute) {
+        this.context.router.history.push('/search');
+      }
+      break;
+    case 'Escape':
+      focusRoot();
+    }
+  }
+
+  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
+            ref={this.setRef}
+            className='search__input'
+            type='text'
+            placeholder={intl.formatMessage(messages.placeholder)}
+            value={value || ''}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyUp}
+            onFocus={this.handleFocus}
+            onBlur={this.handleBlur}
+          />
+        </label>
+
+        <div
+          aria-label={intl.formatMessage(messages.placeholder)}
+          className='search__icon'
+          onClick={this.handleClear}
+          role='button'
+          tabIndex='0'
+        >
+          <Icon id='search' className={hasValue ? '' : 'active'} />
+          <Icon id='times-circle' className={hasValue ? 'active' : ''} />
+        </div>
+
+        <Overlay show={expanded && !hasValue} placement='bottom' target={this}>
+          <SearchPopout />
+        </Overlay>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/search_results.js b/app/javascript/flavours/glitch/features/compose/components/search_results.js
new file mode 100644
index 000000000..fa3487328
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/search_results.js
@@ -0,0 +1,134 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import Icon from 'flavours/glitch/components/icon';
+import { searchEnabled } from 'flavours/glitch/util/initial_state';
+import LoadMore from 'flavours/glitch/components/load_more';
+
+const messages = defineMessages({
+  dismissSuggestion: { id: 'suggestions.dismiss', defaultMessage: 'Dismiss suggestion' },
+});
+
+export default @injectIntl
+class SearchResults extends ImmutablePureComponent {
+
+  static propTypes = {
+    results: ImmutablePropTypes.map.isRequired,
+    suggestions: ImmutablePropTypes.list.isRequired,
+    fetchSuggestions: PropTypes.func.isRequired,
+    expandSearch: PropTypes.func.isRequired,
+    dismissSuggestion: PropTypes.func.isRequired,
+    searchTerm: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    if (this.props.searchTerm === '') {
+      this.props.fetchSuggestions();
+    }
+  }
+
+  handleLoadMoreAccounts = () => this.props.expandSearch('accounts');
+
+  handleLoadMoreStatuses = () => this.props.expandSearch('statuses');
+
+  handleLoadMoreHashtags = () => this.props.expandSearch('hashtags');
+
+  render () {
+    const { intl, results, suggestions, dismissSuggestion, searchTerm } = this.props;
+
+    if (results.isEmpty() && !suggestions.isEmpty()) {
+      return (
+        <div className='drawer--results'>
+          <div className='trends'>
+            <div className='trends__header'>
+              <Icon fixedWidth id='user-plus' />
+              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
+            </div>
+
+            {suggestions && suggestions.map(accountId => (
+              <AccountContainer
+                key={accountId}
+                id={accountId}
+                actionIcon='times'
+                actionTitle={intl.formatMessage(messages.dismissSuggestion)}
+                onActionClick={dismissSuggestion}
+              />
+            ))}
+          </div>
+        </div>
+      );
+    } else if(results.get('statuses') && results.get('statuses').size === 0 && !searchEnabled && !(searchTerm.startsWith('@') || searchTerm.startsWith('#') || searchTerm.includes(' '))) {
+      statuses = (
+        <section>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          <div className='search-results__info'>
+            <FormattedMessage id='search_results.statuses_fts_disabled' defaultMessage='Searching toots by their content is not enabled on this Mastodon server.' />
+          </div>
+        </section>
+      );
+    }
+
+    let accounts, statuses, hashtags;
+    let count = 0;
+
+    if (results.get('accounts') && results.get('accounts').size > 0) {
+      count   += results.get('accounts').size;
+      accounts = (
+        <section>
+          <h5><Icon id='users' fixedWidth /><FormattedMessage id='search_results.accounts' defaultMessage='People' /></h5>
+
+          {results.get('accounts').map(accountId => <AccountContainer id={accountId} key={accountId} />)}
+
+          {results.get('accounts').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreAccounts} />}
+        </section>
+      );
+    }
+
+    if (results.get('statuses') && results.get('statuses').size > 0) {
+      count   += results.get('statuses').size;
+      statuses = (
+        <section>
+          <h5><Icon id='quote-right' fixedWidth /><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></h5>
+
+          {results.get('statuses').map(statusId => <StatusContainer id={statusId} key={statusId}/>)}
+
+          {results.get('statuses').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreStatuses} />}
+        </section>
+      );
+    }
+
+    if (results.get('hashtags') && results.get('hashtags').size > 0) {
+      count += results.get('hashtags').size;
+      hashtags = (
+        <section>
+          <h5><Icon id='hashtag' fixedWidth /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
+
+          {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+
+          {results.get('hashtags').size >= 5 && <LoadMore visible onClick={this.handleLoadMoreHashtags} />}
+        </section>
+      );
+    }
+
+    //  The result.
+    return (
+      <div className='drawer--results'>
+        <header className='search-results__header'>
+          <Icon id='search' fixedWidth />
+          <FormattedMessage id='search_results.total' defaultMessage='{count, number} {count, plural, one {result} other {results}}' values={{ count }} />
+        </header>
+
+        {accounts}
+        {statuses}
+        {hashtags}
+      </div>
+    );
+  };
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
new file mode 100644
index 000000000..7f2005060
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/text_icon_button.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const iconStyle = {
+  height: null,
+  lineHeight: '27px',
+  width: `${18 * 1.28571429}px`,
+};
+
+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}
+        style={iconStyle}
+      >
+        {label}
+      </button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
new file mode 100644
index 000000000..b875fb15e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/textarea_icons.js
@@ -0,0 +1,59 @@
+//  Package imports.
+import PropTypes from 'prop-types';
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+//  Components.
+import Icon from 'flavours/glitch/components/icon';
+
+//  Messages.
+const messages = defineMessages({
+  localOnly: {
+    defaultMessage: 'This post is local-only',
+    id: 'advanced_options.local-only.tooltip',
+  },
+  threadedMode: {
+    defaultMessage: 'Threaded mode enabled',
+    id: 'advanced_options.threaded_mode.tooltip',
+  },
+});
+
+//  We use an array of tuples here instead of an object because it
+//  preserves order.
+const iconMap = [
+  ['do_not_federate', 'home', messages.localOnly],
+  ['threaded_mode', 'comments', messages.threadedMode],
+];
+
+export default @injectIntl
+class TextareaIcons extends ImmutablePureComponent {
+
+  static propTypes = {
+    advancedOptions: ImmutablePropTypes.map,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { advancedOptions, intl } = this.props;
+    return (
+      <div className='composer--textarea--icons'>
+        {advancedOptions ? iconMap.map(
+          ([key, icon, message]) => advancedOptions.get(key) ? (
+            <span
+              className='textarea_icon'
+              key={key}
+              title={intl.formatMessage(message)}
+            >
+              <Icon
+                fixedWidth
+                id={icon}
+              />
+            </span>
+          ) : null
+        ) : null}
+      </div>
+    );
+  }
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload.js b/app/javascript/flavours/glitch/features/compose/components/upload.js
new file mode 100644
index 000000000..425b0fe5e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload.js
@@ -0,0 +1,57 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+
+export default class Upload extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    onUndo: PropTypes.func.isRequired,
+    onOpenFocalPoint: PropTypes.func.isRequired,
+  };
+
+  handleUndoClick = e => {
+    e.stopPropagation();
+    this.props.onUndo(this.props.media.get('id'));
+  }
+
+  handleFocalPointClick = e => {
+    e.stopPropagation();
+    this.props.onOpenFocalPoint(this.props.media.get('id'));
+  }
+
+  render () {
+    const { intl, media } = this.props;
+    const focusX = media.getIn(['meta', 'focus', 'x']);
+    const focusY = media.getIn(['meta', 'focus', 'y']);
+    const x = ((focusX /  2) + .5) * 100;
+    const y = ((focusY / -2) + .5) * 100;
+
+    return (
+      <div className='composer--upload_form--item' tabIndex='0' role='button'>
+        <Motion defaultStyle={{ scale: 0.8 }} style={{ scale: spring(1, { stiffness: 180, damping: 12, }) }}>
+          {({ scale }) => (
+            <div style={{ transform: `scale(${scale})`, backgroundImage: `url(${media.get('preview_url')})`, backgroundPosition: `${x}% ${y}%` }}>
+              <div className={classNames('composer--upload_form--actions', { active: true })}>
+                <button className='icon-button' onClick={this.handleUndoClick}><Icon id='times' /> <FormattedMessage id='upload_form.undo' defaultMessage='Delete' /></button>
+                <button className='icon-button' onClick={this.handleFocalPointClick}><Icon id='pencil' /> <FormattedMessage id='upload_form.edit' defaultMessage='Edit' /></button>
+              </div>
+            </div>
+          )}
+        </Motion>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_form.js b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
new file mode 100644
index 000000000..43039c674
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_form.js
@@ -0,0 +1,34 @@
+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';
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
+import { FormattedMessage } from 'react-intl';
+
+export default class UploadForm extends ImmutablePureComponent {
+  static propTypes = {
+    mediaIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  render () {
+    const { mediaIds } = this.props;
+
+    return (
+      <div className='composer--upload_form'>
+        <UploadProgressContainer icon='upload' message={<FormattedMessage id='upload_progress.label' defaultMessage='Uploading…' />} />
+
+        {mediaIds.size > 0 && (
+          <div className='content'>
+            {mediaIds.map(id => (
+              <UploadContainer id={id} key={id} />
+            ))}
+          </div>
+        )}
+
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/upload_progress.js b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
new file mode 100644
index 000000000..493bb9ca5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/upload_progress.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class UploadProgress extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    progress: PropTypes.number,
+    icon: PropTypes.string.isRequired,
+    message: PropTypes.node.isRequired,
+  };
+
+  render () {
+    const { active, progress, icon, message } = this.props;
+
+    if (!active) {
+      return null;
+    }
+
+    return (
+      <div className='composer--upload_form--progress'>
+        <Icon id={icon} />
+
+        <div className='message'>
+          {message}
+
+          <div className='backdrop'>
+            <Motion defaultStyle={{ width: 0 }} style={{ width: spring(progress) }}>
+              {({ width }) =>
+                (<div className='tracker' style={{ width: `${width}%` }}
+                />)
+              }
+            </Motion>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/components/warning.js b/app/javascript/flavours/glitch/features/compose/components/warning.js
new file mode 100644
index 000000000..6ee3640bc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/components/warning.js
@@ -0,0 +1,26 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/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='composer--warning' style={{ opacity: opacity, transform: `scale(${scaleX}, ${scaleY})` }}>
+            {message}
+          </div>
+        )}
+      </Motion>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js b/app/javascript/flavours/glitch/features/compose/containers/autosuggest_account_container.js
new file mode 100644
index 000000000..0e1c328fe
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/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/flavours/glitch/features/compose/containers/compose_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
new file mode 100644
index 000000000..fcd2caf1b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/compose_form_container.js
@@ -0,0 +1,135 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ComposeForm from '../components/compose_form';
+import {
+  changeCompose,
+  changeComposeSpoilerText,
+  changeComposeSpoilerness,
+  changeComposeVisibility,
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  insertEmojiCompose,
+  selectComposeSuggestion,
+  submitCompose,
+  uploadCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  openModal,
+} from 'flavours/glitch/actions/modal';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+
+import { privacyPreference } from 'flavours/glitch/util/privacy_preference';
+
+const messages = defineMessages({
+  missingDescriptionMessage: {  id: 'confirmations.missing_media_description.message',
+                                defaultMessage: 'At least one media attachment is lacking a description. Consider describing all media attachments for the visually impaired before sending your toot.' },
+  missingDescriptionConfirm: {  id: 'confirmations.missing_media_description.confirm',
+                                defaultMessage: 'Send anyway' },
+  missingDescriptionEdit:    {  id: 'confirmations.missing_media_description.edit',
+                                defaultMessage: 'Edit media' },
+});
+
+//  State mapping.
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+  const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
+  const sideArmBasePrivacy = state.getIn(['local_settings', 'side_arm']);
+  const sideArmRestrictedPrivacy = replyPrivacy ? privacyPreference(replyPrivacy, sideArmBasePrivacy) : null;
+  let sideArmPrivacy = null;
+  switch (state.getIn(['local_settings', 'side_arm_reply_mode'])) {
+    case 'copy':
+      sideArmPrivacy = replyPrivacy;
+      break;
+    case 'restrict':
+      sideArmPrivacy = sideArmRestrictedPrivacy;
+      break;
+  }
+  sideArmPrivacy = sideArmPrivacy || sideArmBasePrivacy;
+  return {
+    advancedOptions: state.getIn(['compose', 'advanced_options']),
+    focusDate: state.getIn(['compose', 'focusDate']),
+    caretPosition: state.getIn(['compose', 'caretPosition']),
+    isSubmitting: state.getIn(['compose', 'is_submitting']),
+    isChangingUpload: state.getIn(['compose', 'is_changing_upload']),
+    isUploading: state.getIn(['compose', 'is_uploading']),
+    layout: state.getIn(['local_settings', 'layout']),
+    media: state.getIn(['compose', 'media_attachments']),
+    preselectDate: state.getIn(['compose', 'preselectDate']),
+    privacy: state.getIn(['compose', 'privacy']),
+    sideArm: sideArmPrivacy,
+    sensitive: state.getIn(['compose', 'sensitive']),
+    showSearch: state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']),
+    spoiler: spoilersAlwaysOn || state.getIn(['compose', 'spoiler']),
+    spoilerText: state.getIn(['compose', 'spoiler_text']),
+    suggestions: state.getIn(['compose', 'suggestions']),
+    text: state.getIn(['compose', 'text']),
+    anyMedia: state.getIn(['compose', 'media_attachments']).size > 0,
+    spoilersAlwaysOn: spoilersAlwaysOn,
+    mediaDescriptionConfirmation: state.getIn(['local_settings', 'confirm_missing_media_description']),
+    preselectOnReply: state.getIn(['local_settings', 'preselect_on_reply']),
+  };
+};
+
+//  Dispatch mapping.
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onChange(text) {
+    dispatch(changeCompose(text));
+  },
+
+  onSubmit(routerHistory) {
+    dispatch(submitCompose(routerHistory));
+  },
+
+  onClearSuggestions() {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions(token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected(position, token, suggestion, path) {
+    dispatch(selectComposeSuggestion(position, token, suggestion, path));
+  },
+
+  onChangeSpoilerText(text) {
+    dispatch(changeComposeSpoilerText(text));
+  },
+
+  onPaste(files) {
+    dispatch(uploadCompose(files));
+  },
+
+  onPickEmoji(position, emoji) {
+    dispatch(insertEmojiCompose(position, emoji));
+  },
+
+  onChangeSpoilerness() {
+    dispatch(changeComposeSpoilerness());
+  },
+
+  onChangeVisibility(value) {
+    dispatch(changeComposeVisibility(value));
+  },
+
+  onMediaDescriptionConfirm(routerHistory, mediaId, overriddenVisibility = null) {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.missingDescriptionMessage),
+      confirm: intl.formatMessage(messages.missingDescriptionConfirm),
+      onConfirm: () => {
+        if (overriddenVisibility) {
+          dispatch(changeComposeVisibility(overriddenVisibility));
+        };
+        dispatch(submitCompose(routerHistory));
+      },
+      secondary: intl.formatMessage(messages.missingDescriptionEdit),
+      onSecondary: () => dispatch(openModal('FOCAL_POINT', { id: mediaId })),
+      onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_missing_media_description'], false)),
+    }));
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(ComposeForm));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/header_container.js b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
new file mode 100644
index 000000000..b4dcb4d56
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/header_container.js
@@ -0,0 +1,35 @@
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect }   from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import Header from '../components/header';
+import { logOut } from 'flavours/glitch/util/log_out';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapStateToProps = state => {
+  return {
+    columns: state.getIn(['settings', 'columns']),
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+    showNotificationsBadge: state.getIn(['local_settings', 'notifications', 'tab_badge']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onSettingsClick (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js b/app/javascript/flavours/glitch/features/compose/containers/navigation_container.js
new file mode 100644
index 000000000..eb630ffbb
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/glitch/util/initial_state';
+
+const mapStateToProps = state => {
+  return {
+    account: state.getIn(['accounts', me]),
+  };
+};
+
+export default connect(mapStateToProps)(NavigationBar);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/options_container.js b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
new file mode 100644
index 000000000..c792aa582
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/options_container.js
@@ -0,0 +1,61 @@
+import { connect } from 'react-redux';
+import Options from '../components/options';
+import {
+  changeComposeAdvancedOption,
+  changeComposeContentType,
+  addPoll,
+  removePoll,
+} from 'flavours/glitch/actions/compose';
+import { closeModal, openModal } from 'flavours/glitch/actions/modal';
+
+function mapStateToProps (state) {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const poll = state.getIn(['compose', 'poll']);
+  const media = state.getIn(['compose', 'media_attachments']);
+  const pending_media = state.getIn(['compose', 'pending_media_attachments']);
+  return {
+    acceptContentTypes: state.getIn(['media_attachments', 'accept_content_types']).toArray().join(','),
+    resetFileKey: state.getIn(['compose', 'resetFileKey']),
+    hasPoll: !!poll,
+    allowMedia: !poll && (media ? media.size + pending_media < 4 && !media.some(item => ['video', 'audio'].includes(item.get('type'))) : pending_media < 4),
+    hasMedia: media && !!media.size,
+    allowPoll: !(media && !!media.size),
+    showContentTypeChoice: state.getIn(['local_settings', 'show_content_type_choice']),
+    contentType: state.getIn(['compose', 'content_type']),
+  };
+};
+
+const mapDispatchToProps = (dispatch) => ({
+
+  onChangeAdvancedOption(option, value) {
+    dispatch(changeComposeAdvancedOption(option, value));
+  },
+
+  onChangeContentType(value) {
+    dispatch(changeComposeContentType(value));
+  },
+
+  onTogglePoll() {
+    dispatch((_, getState) => {
+      if (getState().getIn(['compose', 'poll'])) {
+        dispatch(removePoll());
+      } else {
+        dispatch(addPoll());
+      }
+    });
+  },
+
+  onDoodleOpen() {
+    dispatch(openModal('DOODLE', { noEsc: true }));
+  },
+
+  onModalClose() {
+    dispatch(closeModal());
+  },
+
+  onModalOpen(props) {
+    dispatch(openModal('ACTIONS', props));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Options);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
new file mode 100644
index 000000000..e87e58771
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/poll_form_container.js
@@ -0,0 +1,48 @@
+import { connect } from 'react-redux';
+import PollForm from '../components/poll_form';
+import { addPollOption, removePollOption, changePollOption, changePollSettings } from 'flavours/glitch/actions/compose';
+import {
+  clearComposeSuggestions,
+  fetchComposeSuggestions,
+  selectComposeSuggestion,
+} from 'flavours/glitch/actions/compose';
+
+const mapStateToProps = state => ({
+  suggestions: state.getIn(['compose', 'suggestions']),
+  options: state.getIn(['compose', 'poll', 'options']),
+  expiresIn: state.getIn(['compose', 'poll', 'expires_in']),
+  isMultiple: state.getIn(['compose', 'poll', 'multiple']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onAddOption(title) {
+    dispatch(addPollOption(title));
+  },
+
+  onRemoveOption(index) {
+    dispatch(removePollOption(index));
+  },
+
+  onChangeOption(index, title) {
+    dispatch(changePollOption(index, title));
+  },
+
+  onChangeSettings(expiresIn, isMultiple) {
+    dispatch(changePollSettings(expiresIn, isMultiple));
+  },
+
+  onClearSuggestions () {
+    dispatch(clearComposeSuggestions());
+  },
+
+  onFetchSuggestions (token) {
+    dispatch(fetchComposeSuggestions(token));
+  },
+
+  onSuggestionSelected (position, token, accountId, path) {
+    dispatch(selectComposeSuggestion(position, token, accountId, path));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(PollForm);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
new file mode 100644
index 000000000..395a9aa5b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/reply_indicator_container.js
@@ -0,0 +1,22 @@
+import { connect } from 'react-redux';
+import { cancelReplyCompose } from 'flavours/glitch/actions/compose';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import ReplyIndicator from '../components/reply_indicator';
+
+function makeMapStateToProps (state) {
+  const inReplyTo = state.getIn(['compose', 'in_reply_to']);
+
+  return {
+    status: inReplyTo ? state.getIn(['statuses', inReplyTo]) : null,
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onCancel () {
+    dispatch(cancelReplyCompose());
+  },
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(ReplyIndicator);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/search_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
new file mode 100644
index 000000000..8f4bfcf08
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_container.js
@@ -0,0 +1,35 @@
+import { connect } from 'react-redux';
+import {
+  changeSearch,
+  clearSearch,
+  submitSearch,
+  showSearch,
+} from 'flavours/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/flavours/glitch/features/compose/containers/search_results_container.js b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
new file mode 100644
index 000000000..5c2c1be23
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/search_results_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import SearchResults from '../components/search_results';
+import { fetchSuggestions, dismissSuggestion } from 'flavours/glitch/actions/suggestions';
+import { expandSearch } from 'flavours/glitch/actions/search';
+
+const mapStateToProps = state => ({
+  results: state.getIn(['search', 'results']),
+  suggestions: state.getIn(['suggestions', 'items']),
+  searchTerm: state.getIn(['search', 'searchTerm']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchSuggestions: () => dispatch(fetchSuggestions()),
+  expandSearch: type => dispatch(expandSearch(type)),
+  dismissSuggestion: account => dispatch(dismissSuggestion(account.get('id'))),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(SearchResults);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
new file mode 100644
index 000000000..fa1ae8821
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/sensitive_button_container.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { changeComposeSensitivity } from 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
+
+const messages = defineMessages({
+  marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
+  unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
+});
+
+const mapStateToProps = state => {
+  const spoilersAlwaysOn = state.getIn(['local_settings', 'always_show_spoilers_field']);
+  const spoilerText = state.getIn(['compose', 'spoiler_text']);
+  return {
+    active: state.getIn(['compose', 'sensitive']) || (spoilersAlwaysOn && spoilerText && spoilerText.length > 0),
+    disabled: state.getIn(['compose', 'spoiler']),
+  };
+};
+
+const mapDispatchToProps = dispatch => ({
+
+  onClick () {
+    dispatch(changeComposeSensitivity());
+  },
+
+});
+
+class SensitiveButton extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    disabled: PropTypes.bool,
+    onClick: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { active, disabled, onClick, intl } = this.props;
+
+    return (
+      <div className='compose-form__sensitive-button'>
+        <label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
+          <input
+            name='mark-sensitive'
+            type='checkbox'
+            checked={active}
+            onChange={onClick}
+            disabled={disabled}
+          />
+
+          <span className={classNames('checkbox', { active })} />
+
+          <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
+        </label>
+      </div>
+    );
+  }
+
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(injectIntl(SensitiveButton));
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
new file mode 100644
index 000000000..f687fae99
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/upload_container.js
@@ -0,0 +1,27 @@
+import { connect } from 'react-redux';
+import Upload from '../components/upload';
+import { undoUploadCompose } from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { submitCompose } from 'flavours/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));
+  },
+
+  onOpenFocalPoint: id => {
+    dispatch(openModal('FOCAL_POINT', { id }));
+  },
+
+  onSubmit (router) {
+    dispatch(submitCompose(router));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Upload);
diff --git a/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_form_container.js
new file mode 100644
index 000000000..a6798bf51
--- /dev/null
+++ b/app/javascript/flavours/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/flavours/glitch/features/compose/containers/upload_progress_container.js b/app/javascript/flavours/glitch/features/compose/containers/upload_progress_container.js
new file mode 100644
index 000000000..0cfee96da
--- /dev/null
+++ b/app/javascript/flavours/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/flavours/glitch/features/compose/containers/warning_container.js b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
new file mode 100644
index 000000000..b9b0a2644
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/containers/warning_container.js
@@ -0,0 +1,45 @@
+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 'flavours/glitch/util/initial_state';
+import { profileLink, termsLink } from 'flavours/glitch/util/backend_links';
+
+const APPROX_HASHTAG_RE = /(?:^|[^\/\)\w])#(\w*[a-zA-Z·]\w*)/i;
+
+const mapStateToProps = state => ({
+  needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', me, 'locked']),
+  hashtagWarning: state.getIn(['compose', 'privacy']) !== 'public' && APPROX_HASHTAG_RE.test(state.getIn(['compose', 'text'])),
+  directMessageWarning: state.getIn(['compose', 'privacy']) === 'direct',
+});
+
+const WarningWrapper = ({ needsLockWarning, hashtagWarning, directMessageWarning }) => {
+  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={profileLink}><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />;
+  }
+
+  if (hashtagWarning) {
+    return <Warning message={<FormattedMessage id='compose_form.hashtag_warning' defaultMessage="This toot won't be listed under any hashtag as it is unlisted. Only public toots can be searched by hashtag." />} />;
+  }
+
+  if (directMessageWarning) {
+    const message = (
+      <span>
+        <FormattedMessage id='compose_form.direct_message_warning' defaultMessage='This toot will only be sent to all the mentioned users.' /> {!!termsLink && <a href='/terms' target='_blank'><FormattedMessage id='compose_form.direct_message_warning_learn_more' defaultMessage='Learn more' /></a>}
+      </span>
+    );
+
+    return <Warning message={message} />;
+  }
+
+  return null;
+};
+
+WarningWrapper.propTypes = {
+  needsLockWarning: PropTypes.bool,
+  hashtagWarning: PropTypes.bool,
+  directMessageWarning: PropTypes.bool,
+};
+
+export default connect(mapStateToProps)(WarningWrapper);
diff --git a/app/javascript/flavours/glitch/features/compose/index.js b/app/javascript/flavours/glitch/features/compose/index.js
new file mode 100644
index 000000000..cb6c54a48
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/compose/index.js
@@ -0,0 +1,111 @@
+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 'flavours/glitch/actions/compose';
+import { injectIntl, defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import SearchContainer from './containers/search_container';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+import SearchResultsContainer from './containers/search_results_container';
+import { me, mascot } from 'flavours/glitch/util/initial_state';
+import { cycleElefriendCompose } from 'flavours/glitch/actions/compose';
+import HeaderContainer from './containers/header_container';
+
+const messages = defineMessages({
+  compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
+});
+
+const mapStateToProps = (state, ownProps) => ({
+  elefriend: state.getIn(['compose', 'elefriend']),
+  showSearch: ownProps.multiColumn ? state.getIn(['search', 'submitted']) && !state.getIn(['search', 'hidden']) : ownProps.isSearchPage,
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onClickElefriend () {
+    dispatch(cycleElefriendCompose());
+  },
+
+  onMount () {
+    dispatch(mountCompose());
+  },
+
+  onUnmount () {
+    dispatch(unmountCompose());
+  },
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Compose extends React.PureComponent {
+  static propTypes = {
+    multiColumn: PropTypes.bool,
+    showSearch: PropTypes.bool,
+    isSearchPage: PropTypes.bool,
+    elefriend: PropTypes.number,
+    onClickElefriend: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount () {
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.onMount();
+    }
+  }
+
+  componentWillUnmount () {
+    const { isSearchPage } = this.props;
+
+    if (!isSearchPage) {
+      this.props.onUnmount();
+    }
+  }
+
+  render () {
+    const {
+      elefriend,
+      intl,
+      multiColumn,
+      onClickElefriend,
+      isSearchPage,
+      showSearch,
+    } = this.props;
+    const computedClass = classNames('drawer', `mbstobon-${elefriend}`);
+
+    return (
+      <div className={computedClass} role='region' aria-label={intl.formatMessage(messages.compose)}>
+        {multiColumn && <HeaderContainer />}
+
+        {(multiColumn || isSearchPage) && <SearchContainer />}
+
+        <div className='drawer__pager'>
+          {!isSearchPage && <div className='drawer__inner'>
+            <NavigationContainer />
+
+            <ComposeFormContainer />
+
+            <div className='drawer__inner__mastodon'>
+              {mascot ? <img alt='' draggable='false' src={mascot} /> : <button className='mastodon' onClick={onClickElefriend} />}
+            </div>
+          </div>}
+
+          <Motion defaultStyle={{ x: isSearchPage ? 0 : -100 }} style={{ x: spring(showSearch || isSearchPage ? 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/flavours/glitch/features/direct_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/direct_timeline/components/column_settings.js
new file mode 100644
index 000000000..ce14e2a9d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_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 '../../../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' },
+});
+
+export default @injectIntl
+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} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
new file mode 100644
index 000000000..47b92560d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
@@ -0,0 +1,235 @@
+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 'flavours/glitch/components/status_content';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import AvatarComposite from 'flavours/glitch/components/avatar_composite';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import { HotKeys } from 'react-hotkeys';
+import { autoPlayGif } from 'flavours/glitch/util/initial_state';
+import classNames from 'classnames';
+
+const messages = defineMessages({
+  more: { id: 'status.more', defaultMessage: 'More' },
+  open: { id: 'conversation.open', defaultMessage: 'View conversation' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  markAsRead: { id: 'conversation.mark_as_read', defaultMessage: 'Mark as read' },
+  delete: { id: 'conversation.delete', defaultMessage: 'Delete conversation' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+});
+
+export default @injectIntl
+class Conversation extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    conversationId: PropTypes.string.isRequired,
+    accounts: ImmutablePropTypes.list.isRequired,
+    lastStatus: ImmutablePropTypes.map,
+    unread:PropTypes.bool.isRequired,
+    onMoveUp: PropTypes.func,
+    onMoveDown: PropTypes.func,
+    markRead: PropTypes.func.isRequired,
+    delete: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    isExpanded: undefined,
+  };
+
+  parseClick = (e, destination) => {
+    const { router } = this.context;
+    const { lastStatus, unread, markRead } = this.props;
+    if (!router) return;
+
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
+      if (destination === undefined) {
+        if (unread) {
+          markRead();
+        }
+        destination = `/statuses/${lastStatus.get('id')}`;
+      }
+      let state = {...router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      router.history.push(destination, state);
+      e.preventDefault();
+    }
+  }
+
+  _updateEmojis () {
+    const node = this.namesNode;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  handleClick = () => {
+    if (!this.context.router) {
+      return;
+    }
+
+    const { lastStatus, unread, markRead } = this.props;
+
+    if (unread) {
+      markRead();
+    }
+
+    this.context.router.history.push(`/statuses/${lastStatus.get('id')}`);
+  }
+
+  handleMarkAsRead = () => {
+    this.props.markRead();
+  }
+
+  handleReply = () => {
+    this.props.reply(this.props.lastStatus, this.context.router.history);
+  }
+
+  handleDelete = () => {
+    this.props.delete();
+  }
+
+  handleHotkeyMoveUp = () => {
+    this.props.onMoveUp(this.props.conversationId);
+  }
+
+  handleHotkeyMoveDown = () => {
+    this.props.onMoveDown(this.props.conversationId);
+  }
+
+  handleConversationMute = () => {
+    this.props.onMute(this.props.lastStatus);
+  }
+
+  handleShowMore = () => {
+    if (this.props.lastStatus.get('spoiler_text')) {
+      this.setExpansion(!this.state.isExpanded);
+    }
+  };
+
+  setExpansion = value => {
+    this.setState({ isExpanded: value });
+  }
+
+  setNamesRef = (c) => {
+    this.namesNode = c;
+  }
+
+  render () {
+    const { accounts, lastStatus, unread, intl } = this.props;
+    const { isExpanded } = this.state;
+
+    if (lastStatus === null) {
+      return null;
+    }
+
+    const menu = [
+      { text: intl.formatMessage(messages.open), action: this.handleClick },
+      null,
+    ];
+
+    menu.push({ text: intl.formatMessage(lastStatus.get('muted') ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMute });
+
+    if (unread) {
+      menu.push({ text: intl.formatMessage(messages.markAsRead), action: this.handleMarkAsRead });
+      menu.push(null);
+    }
+
+    menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
+
+    const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
+
+    const handlers = {
+      reply: this.handleReply,
+      open: this.handleClick,
+      moveUp: this.handleHotkeyMoveUp,
+      moveDown: this.handleHotkeyMoveDown,
+      toggleHidden: this.handleShowMore,
+    };
+
+    let media = null;
+    if (lastStatus.get('media_attachments').size > 0) {
+      media = <AttachmentList compact media={lastStatus.get('media_attachments')} />;
+    }
+
+    return (
+      <HotKeys handlers={handlers}>
+        <div className={classNames('conversation focusable muted', { 'conversation--unread': unread })} tabIndex='0'>
+          <div className='conversation__avatar' onClick={this.handleClick} role='presentation'>
+            <AvatarComposite accounts={accounts} size={48} />
+          </div>
+
+          <div className='conversation__content'>
+            <div className='conversation__content__info'>
+              <div className='conversation__content__relative-time'>
+                {unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
+              </div>
+
+              <div className='conversation__content__names' ref={this.setNamesRef}>
+                <FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
+              </div>
+            </div>
+
+            <StatusContent
+              status={lastStatus}
+              parseClick={this.parseClick}
+              expanded={isExpanded}
+              onExpandedToggle={this.handleShowMore}
+              collapsable
+              media={media}
+            />
+
+            <div className='status__action-bar'>
+              <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReply} />
+
+              <div className='status__action-bar-dropdown'>
+                <DropdownMenuContainer status={lastStatus} items={menu} icon='ellipsis-h' size={18} direction='right' title={intl.formatMessage(messages.more)} />
+              </div>
+            </div>
+          </div>
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js
new file mode 100644
index 000000000..4fa76fd6d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversations_list.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ConversationContainer from '../containers/conversation_container';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { debounce } from 'lodash';
+
+export default class ConversationsList extends ImmutablePureComponent {
+
+  static propTypes = {
+    conversations: ImmutablePropTypes.list.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    onLoadMore: PropTypes.func,
+  };
+
+  getCurrentIndex = id => this.props.conversations.findIndex(x => x.get('id') === id)
+
+  handleMoveUp = id => {
+    const elementIndex = this.getCurrentIndex(id) - 1;
+    this._selectChild(elementIndex, true);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.getCurrentIndex(id) + 1;
+    this._selectChild(elementIndex, false);
+  }
+
+  _selectChild (index, align_top) {
+    const container = this.node.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.conversations.last();
+
+    if (last && last.get('last_status')) {
+      this.props.onLoadMore(last.get('last_status'));
+    }
+  }, 300, { leading: true })
+
+  render () {
+    const { conversations, onLoadMore, ...other } = this.props;
+
+    return (
+      <ScrollableList {...other} onLoadMore={onLoadMore && this.handleLoadOlder} scrollKey='direct' ref={this.setRef}>
+        {conversations.map(item => (
+          <ConversationContainer
+            key={item.get('id')}
+            conversationId={item.get('id')}
+            onMoveUp={this.handleMoveUp}
+            onMoveDown={this.handleMoveDown}
+          />
+        ))}
+      </ScrollableList>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..6385d30a4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/column_settings_container.js
@@ -0,0 +1,17 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'direct']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (path, checked) {
+    dispatch(changeSetting(['direct', ...path], checked));
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
new file mode 100644
index 000000000..b15ce9f0f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
@@ -0,0 +1,74 @@
+import { connect } from 'react-redux';
+import Conversation from '../components/conversation';
+import { markConversationRead, deleteConversation } from 'flavours/glitch/actions/conversations';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { replyCompose } from 'flavours/glitch/actions/compose';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { muteStatus, unmuteStatus, hideStatus, revealStatus } from 'flavours/glitch/actions/statuses';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const mapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  return (state, { conversationId }) => {
+    const conversation = state.getIn(['conversations', 'items']).find(x => x.get('id') === conversationId);
+    const lastStatusId = conversation.get('last_status', null);
+
+    return {
+      accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
+      unread: conversation.get('unread'),
+      lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
+    };
+  };
+};
+
+const mapDispatchToProps = (dispatch, { intl, conversationId }) => ({
+
+  markRead () {
+    dispatch(markConversationRead(conversationId));
+  },
+
+  reply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  delete () {
+    dispatch(deleteConversation(conversationId));
+  },
+
+  onMute (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Conversation));
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
new file mode 100644
index 000000000..e10558f3a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversations_list_container.js
@@ -0,0 +1,15 @@
+import { connect } from 'react-redux';
+import ConversationsList from '../components/conversations_list';
+import { expandConversations } from 'flavours/glitch/actions/conversations';
+
+const mapStateToProps = state => ({
+  conversations: state.getIn(['conversations', 'items']),
+  isLoading: state.getIn(['conversations', 'isLoading'], true),
+  hasMore: state.getIn(['conversations', 'hasMore'], false),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onLoadMore: maxId => dispatch(expandConversations({ maxId })),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ConversationsList);
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/index.js b/app/javascript/flavours/glitch/features/direct_timeline/index.js
new file mode 100644
index 000000000..7741c6922
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/direct_timeline/index.js
@@ -0,0 +1,178 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { expandDirectTimeline } from 'flavours/glitch/actions/timelines';
+import { mountConversations, unmountConversations, expandConversations } from 'flavours/glitch/actions/conversations';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectDirectStream } from 'flavours/glitch/actions/streaming';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import ConversationsListContainer from './containers/conversations_list_container';
+
+const messages = defineMessages({
+  title: { id: 'column.direct', defaultMessage: 'Direct messages' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0,
+  conversationsMode: state.getIn(['settings', 'direct', 'conversations']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class DirectTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    conversationsMode: 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, conversationsMode } = this.props;
+
+    dispatch(mountConversations());
+
+    if (conversationsMode) {
+      dispatch(expandConversations());
+    } else {
+      dispatch(expandDirectTimeline());
+    }
+
+    this.disconnect = dispatch(connectDirectStream());
+  }
+
+  componentDidUpdate(prevProps) {
+    const { dispatch, conversationsMode } = this.props;
+
+    if (prevProps.conversationsMode && !conversationsMode) {
+      dispatch(expandDirectTimeline());
+    } else if (!prevProps.conversationsMode && conversationsMode) {
+      dispatch(expandConversations());
+    }
+  }
+
+  componentWillUnmount () {
+    this.props.dispatch(unmountConversations());
+
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMoreTimeline = maxId => {
+    this.props.dispatch(expandDirectTimeline({ maxId }));
+  }
+
+  handleLoadMoreConversations = maxId => {
+    this.props.dispatch(expandConversations({ maxId }));
+  }
+
+  handleTimelineClick = () => {
+    this.props.dispatch(changeSetting(['direct', 'conversations'], false));
+  }
+
+  handleConversationsClick = () => {
+    this.props.dispatch(changeSetting(['direct', 'conversations'], true));
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, conversationsMode } = this.props;
+    const pinned = !!columnId;
+
+    let contents;
+    if (conversationsMode) {
+      contents = (
+        <ConversationsListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={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." />}
+        />
+      );
+    } else {
+      contents = (
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`direct_timeline-${columnId}`}
+          timelineId='direct'
+          onLoadMore={this.handleLoadMoreTimeline}
+          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." />}
+        />
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <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>
+
+        <div className='notification__filter-bar'>
+          <button
+            className={conversationsMode ? 'active' : ''}
+            onClick={this.handleConversationsClick}
+          >
+            <FormattedMessage
+              id='direct.conversations_mode'
+              defaultMessage='Conversations'
+            />
+          </button>
+          <button
+            className={conversationsMode ? '' : 'active'}
+            onClick={this.handleTimelineClick}
+          >
+            <FormattedMessage
+              id='direct.timeline_mode'
+              defaultMessage='Timeline'
+            />
+          </button>
+        </div>
+
+        {contents}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/directory/components/account_card.js b/app/javascript/flavours/glitch/features/directory/components/account_card.js
new file mode 100644
index 000000000..557120960
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/components/account_card.js
@@ -0,0 +1,191 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
+import { autoPlayGif, me, unfollowModal } from 'flavours/glitch/util/initial_state';
+import { shortNumberFormat } from 'flavours/glitch/util/numbers';
+import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'flavours/glitch/actions/accounts';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+
+const messages = defineMessages({
+  follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
+  unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
+  unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
+  unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
+});
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { id }) => ({
+    account: getAccount(state, id),
+  });
+
+  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(blockAccount(account.get('id')));
+    }
+  },
+
+  onMute (account) {
+    if (account.getIn(['relationship', 'muting'])) {
+      dispatch(unmuteAccount(account.get('id')));
+    } else {
+      dispatch(initMuteModal(account));
+    }
+  },
+
+});
+
+export default @injectIntl
+@connect(makeMapStateToProps, mapDispatchToProps)
+class AccountCard extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onFollow: PropTypes.func.isRequired,
+    onBlock: PropTypes.func.isRequired,
+    onMute: PropTypes.func.isRequired,
+  };
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  componentDidMount () {
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateEmojis();
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  handleFollow = () => {
+    this.props.onFollow(this.props.account);
+  }
+
+  handleBlock = () => {
+    this.props.onBlock(this.props.account);
+  }
+
+  handleMute = () => {
+    this.props.onMute(this.props.account);
+  }
+
+  setRef = (c) => {
+    this.node = c;
+  }
+
+  render () {
+    const { account, intl } = this.props;
+
+    let buttons;
+
+    if (account.get('id') !== me && account.get('relationship', null) !== null) {
+      const following = account.getIn(['relationship', 'following']);
+      const requested = account.getIn(['relationship', 'requested']);
+      const blocking  = account.getIn(['relationship', 'blocking']);
+      const muting    = account.getIn(['relationship', 'muting']);
+
+      if (requested) {
+        buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />;
+      } else if (blocking) {
+        buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />;
+      } else if (muting) {
+        buttons = <IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} />;
+      } else if (!account.get('moved') || following) {
+        buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />;
+      }
+    }
+
+    return (
+      <div className='directory__card'>
+        <div className='directory__card__img'>
+          <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' />
+        </div>
+
+        <div className='directory__card__bar'>
+          <Permalink className='directory__card__bar__name' href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+            <Avatar account={account} size={48} />
+            <DisplayName account={account} />
+          </Permalink>
+
+          <div className='directory__card__bar__relationship account__relationship'>
+            {buttons}
+          </div>
+        </div>
+
+        <div className='directory__card__extra' ref={this.setRef}>
+          <div className='account__header__content' dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }} />
+        </div>
+
+        <div className='directory__card__extra'>
+          <div className='accounts-table__count'>{shortNumberFormat(account.get('statuses_count'))} <small><FormattedMessage id='account.posts' defaultMessage='Toots' /></small></div>
+          <div className='accounts-table__count'>{account.get('followers_count') < 0 ? '-' : shortNumberFormat(account.get('followers_count'))} <small><FormattedMessage id='account.followers' defaultMessage='Followers' /></small></div>
+          <div className='accounts-table__count'>{account.get('last_status_at') === null ? <FormattedMessage id='account.never_active' defaultMessage='Never' /> : <RelativeTimestamp timestamp={account.get('last_status_at')} />} <small><FormattedMessage id='account.last_status' defaultMessage='Last active' /></small></div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/directory/index.js b/app/javascript/flavours/glitch/features/directory/index.js
new file mode 100644
index 000000000..858a8fa55
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/directory/index.js
@@ -0,0 +1,171 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'flavours/glitch/actions/columns';
+import { fetchDirectory, expandDirectory } from 'flavours/glitch/actions/directory';
+import { List as ImmutableList } from 'immutable';
+import AccountCard from './components/account_card';
+import RadioButton from 'flavours/glitch/components/radio_button';
+import classNames from 'classnames';
+import LoadMore from 'flavours/glitch/components/load_more';
+import { ScrollContainer } from 'react-router-scroll-4';
+
+const messages = defineMessages({
+  title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
+  recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
+  newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
+  local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
+  federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
+  isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Directory extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    columnId: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+    params: PropTypes.shape({
+      order: PropTypes.string,
+      local: PropTypes.bool,
+    }),
+  };
+
+  state = {
+    order: null,
+    local: null,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
+    }
+  }
+
+  getParams = (props, state) => ({
+    order: state.order === null ? (props.params.order || 'active') : state.order,
+    local: state.local === null ? (props.params.local || false) : state.local,
+  });
+
+  handleMove = dir => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchDirectory(this.getParams(this.props, this.state)));
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    const { dispatch } = this.props;
+    const paramsOld = this.getParams(prevProps, prevState);
+    const paramsNew = this.getParams(this.props, this.state);
+
+    if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
+      dispatch(fetchDirectory(paramsNew));
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleChangeOrder = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['order'], e.target.value));
+    } else {
+      this.setState({ order: e.target.value });
+    }
+  }
+
+  handleChangeLocal = e => {
+    const { dispatch, columnId } = this.props;
+
+    if (columnId) {
+      dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
+    } else {
+      this.setState({ local: e.target.value === '1' });
+    }
+  }
+
+  handleLoadMore = () => {
+    const { dispatch } = this.props;
+    dispatch(expandDirectory(this.getParams(this.props, this.state)));
+  }
+
+  render () {
+    const { isLoading, accountIds, intl, columnId, multiColumn, domain, shouldUpdateScroll } = this.props;
+    const { order, local }  = this.getParams(this.props, this.state);
+    const pinned = !!columnId;
+
+    const scrollableArea = (
+      <div className='scrollable' style={{ background: 'transparent' }}>
+        <div className='filter-form'>
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
+            <RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
+          </div>
+
+          <div className='filter-form__column' role='group'>
+            <RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
+            <RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
+          </div>
+        </div>
+
+        <div className={classNames('directory__list', { loading: isLoading })}>
+          {accountIds.map(accountId => <AccountCard id={accountId} key={accountId} />)}
+        </div>
+
+        <LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='address-book-o'
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        />
+
+        {multiColumn && !pinned ? <ScrollContainer scrollKey='directory' shouldUpdateScroll={shouldUpdateScroll}>{scrollableArea}</ScrollContainer> : scrollableArea}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/domain_blocks/index.js b/app/javascript/flavours/glitch/features/domain_blocks/index.js
new file mode 100644
index 000000000..acce87d5a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/domain_blocks/index.js
@@ -0,0 +1,77 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import PropTypes from 'prop-types';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import ColumnBackButtonSlim from '../../components/column_back_button_slim';
+import DomainContainer from '../../containers/domain_container';
+import { fetchDomainBlocks, expandDomainBlocks } from '../../actions/domain_blocks';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  heading: { id: 'column.domain_blocks', defaultMessage: 'Blocked domains' },
+  unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
+});
+
+const mapStateToProps = state => ({
+  domains: state.getIn(['domain_lists', 'blocks', 'items']),
+  hasMore: !!state.getIn(['domain_lists', 'blocks', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Blocks extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    domains: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchDomainBlocks());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandDomainBlocks());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, domains, hasMore, multiColumn } = this.props;
+
+    if (!domains) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.domain_blocks' defaultMessage='There are no blocked domains yet.' />;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='minus-circle' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollableList
+          scrollKey='domain_blocks'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {domains.map(domain =>
+            <DomainContainer key={domain} domain={domain} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/emoji_picker/index.js b/app/javascript/flavours/glitch/features/emoji_picker/index.js
new file mode 100644
index 000000000..d0d9714a8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/emoji_picker/index.js
@@ -0,0 +1,466 @@
+import { connect } from 'react-redux';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+import { useEmoji } from 'flavours/glitch/actions/emojis';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import { EmojiPicker as EmojiPickerAsync } from 'flavours/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, categoriesFromEmojis } from 'flavours/glitch/util/emoji';
+import { useSystemEmojiFont } from 'flavours/glitch/util/initial_state';
+
+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 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);
+    }
+  },
+});
+
+const assetHost = process.env.CDN_HOST || '';
+let EmojiPicker, Emoji; // load asynchronously
+
+const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
+const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+
+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} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={2}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={3}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={4}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={5}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></button>
+        <button onClick={this.handleClick} data-index={6}><Emoji emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} native={useSystemEmojiFont} /></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} native={useSystemEmojiFont} />
+        <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,
+    frequentlyUsedEmojis: [],
+  };
+
+  state = {
+    modifierOpen: false,
+    placement: null,
+  };
+
+  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, event) => {
+    if (!emoji.native) {
+      emoji.native = emoji.colons;
+    }
+    if (!(event.ctrlKey || event.metaKey)) {
+      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;
+
+    const categoriesSort = [
+      'recent',
+      'people',
+      'nature',
+      'foods',
+      'activity',
+      'places',
+      'objects',
+      'symbols',
+      'flags',
+    ];
+
+    categoriesSort.splice(1, 0, ...Array.from(categoriesFromEmojis(custom_emojis)).sort());
+
+    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}
+          autoFocus
+          emojiTooltip
+          native={useSystemEmojiFont}
+        />
+
+        <ModifierPicker
+          active={modifierOpen}
+          modifier={skinTone}
+          onOpen={this.handleModifierOpen}
+          onClose={this.handleModifierClose}
+          onChange={this.handleModifierChange}
+        />
+      </div>
+    );
+  }
+
+}
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+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,
+    button: PropTypes.node,
+  };
+
+  state = {
+    active: false,
+    loading: false,
+  };
+
+  setRef = (c) => {
+    this.dropdown = c;
+  }
+
+  onShowDropdown = ({ target }) => {
+    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 });
+      });
+    }
+
+    const { top } = target.getBoundingClientRect();
+    this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
+  }
+
+  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(e);
+      }
+    }
+  }
+
+  handleKeyDown = e => {
+    if (e.key === 'Escape') {
+      this.onHideDropdown();
+    }
+  }
+
+  setTargetRef = c => {
+    this.target = c;
+  }
+
+  findTarget = () => {
+    return this.target;
+  }
+
+  render () {
+    const { intl, onPickEmoji, onSkinTone, skinTone, frequentlyUsedEmojis, button } = this.props;
+    const title = intl.formatMessage(messages.emoji);
+    const { active, loading, placement } = 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}>
+          {button || <img
+            className={classNames('emojione', { 'pulse-loading': active && loading })}
+            alt='🙂'
+            src={`${assetHost}/emoji/1f602.svg`}
+          />}
+        </div>
+
+        <Overlay show={active} placement={placement} 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/flavours/glitch/features/favourited_statuses/index.js b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
new file mode 100644
index 000000000..c6470ba74
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourited_statuses/index.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import { fetchFavouritedStatuses, expandFavouritedStatuses } from 'flavours/glitch/actions/favourites';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import StatusList from 'flavours/glitch/components/status_list';
+import { defineMessages, injectIntl, FormattedMessage } 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']),
+  isLoading: state.getIn(['status_lists', 'favourites', 'isLoading'], true),
+  hasMore: !!state.getIn(['status_lists', 'favourites', 'next']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+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,
+    isLoading: 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;
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFavouritedStatuses());
+  }, 300, { leading: true })
+
+  render () {
+    const { intl, statusIds, columnId, multiColumn, hasMore, isLoading } = this.props;
+    const pinned = !!columnId;
+
+    const emptyMessage = <FormattedMessage id='empty_column.favourited_statuses' defaultMessage="You don't have any favourite toots yet. When you favourite one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='favourites' label={intl.formatMessage(messages.heading)}>
+        <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}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/favourites/index.js b/app/javascript/flavours/glitch/features/favourites/index.js
new file mode 100644
index 000000000..bd6f782ce
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/favourites/index.js
@@ -0,0 +1,98 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { fetchFavourites } from 'flavours/glitch/actions/interactions';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import Icon from 'flavours/glitch/components/icon';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from '../../components/scrollable_list';
+
+const messages = defineMessages({
+  heading: { id: 'column.favourited_by', defaultMessage: 'Favourited by' },
+  refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'favourited_by', props.params.statusId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Favourites extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      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));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleRefresh = () => {
+    this.props.dispatch(fetchFavourites(this.props.params.statusId));
+  }
+
+  render () {
+    const { intl, accountIds, multiColumn } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.favourites' defaultMessage='No one has favourited this toot yet. When someone does, they will show up here.' />;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='star'
+          title={intl.formatMessage(messages.heading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
+          )}
+        />
+        <ScrollableList
+          scrollKey='favourites'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js b/app/javascript/flavours/glitch/features/follow_requests/components/account_authorize.js
new file mode 100644
index 000000000..bf145cb67
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/glitch/components/permalink';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/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' },
+});
+
+export default @injectIntl
+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/flavours/glitch/features/follow_requests/containers/account_authorize_container.js b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js
new file mode 100644
index 000000000..693e98e8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_requests/containers/account_authorize_container.js
@@ -0,0 +1,26 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import AccountAuthorize from '../components/account_authorize';
+import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/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/flavours/glitch/features/follow_requests/index.js b/app/javascript/flavours/glitch/features/follow_requests/index.js
new file mode 100644
index 000000000..36a57d1d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/follow_requests/index.js
@@ -0,0 +1,95 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountAuthorizeContainer from './containers/account_authorize_container';
+import { fetchFollowRequests, expandFollowRequests } from 'flavours/glitch/actions/accounts';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const messages = defineMessages({
+  heading: { id: 'column.follow_requests', defaultMessage: 'Follow requests' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'follow_requests', 'items']),
+  isLoading: state.getIn(['user_lists', 'follow_requests', 'isLoading'], true),
+  hasMore: !!state.getIn(['user_lists', 'follow_requests', 'next']),
+  locked: !!state.getIn(['accounts', me, 'locked']),
+  domain: state.getIn(['meta', 'domain']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class FollowRequests extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list,
+    locked: PropTypes.bool,
+    domain: PropTypes.string,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchFollowRequests());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowRequests());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, locked, domain, isLoading } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column name='follow-requests'>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.follow_requests' defaultMessage="You don't have any follow requests yet. When you receive one, it will show up here." />;
+    const unlockedPrependMessage = locked ? null : (
+      <div className='follow_requests-unlocked_explanation'>
+        <FormattedMessage
+          id='follow_requests.unlocked_explanation'
+          defaultMessage='Even though your account is not locked, the {domain} staff thought you might want to review follow requests from these accounts manually.'
+          values={{ domain: domain }}
+        />
+      </div>
+    );
+
+    return (
+      <Column bindToDocument={!multiColumn} name='follow-requests' icon='user-plus' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <ScrollableList
+          scrollKey='follow_requests'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+          prepend={unlockedPrependMessage}
+        >
+          {accountIds.map(id =>
+            <AccountAuthorizeContainer key={id} id={id} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
new file mode 100644
index 000000000..8ae46be94
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -0,0 +1,132 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowers,
+  expandFollowers,
+} from 'flavours/glitch/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+
+const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
+  isAccount: !!state.getIn(['accounts', props.params.accountId]),
+  accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
+  isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
+});
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class Followers extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      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));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowers(this.props.params.accountId));
+  }, 300, { leading: true });
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollableList
+          scrollKey='followers'
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          alwaysPrepend
+          append={remoteMessage}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
new file mode 100644
index 000000000..e06eaa8a6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -0,0 +1,132 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import {
+  fetchAccount,
+  fetchFollowing,
+  expandFollowing,
+} from 'flavours/glitch/actions/accounts';
+import { FormattedMessage } from 'react-intl';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header';
+import HeaderContainer from 'flavours/glitch/features/account_timeline/containers/header_container';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import TimelineHint from 'flavours/glitch/components/timeline_hint';
+
+const mapStateToProps = (state, props) => ({
+  remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
+  remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
+  isAccount: !!state.getIn(['accounts', props.params.accountId]),
+  accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
+  hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
+  isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
+});
+
+const RemoteHint = ({ url }) => (
+  <TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
+);
+
+RemoteHint.propTypes = {
+  url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class Following extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    isAccount: PropTypes.bool,
+    remote: PropTypes.bool,
+    remoteUrl: PropTypes.string,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      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));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandFollowing(this.props.params.accountId));
+  }, 300, { leading: true });
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;
+
+    if (!isAccount) {
+      return (
+        <Column>
+          <MissingIndicator />
+        </Column>
+      );
+    }
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    let emptyMessage;
+
+    if (remote && accountIds.isEmpty()) {
+      emptyMessage = <RemoteHint url={remoteUrl} />;
+    } else {
+      emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
+    }
+
+    const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
+
+    return (
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
+
+        <ScrollableList
+          scrollKey='following'
+          hasMore={hasMore}
+          isLoading={isLoading}
+          onLoadMore={this.handleLoadMore}
+          prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
+          alwaysPrepend
+          append={remoteMessage}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/generic_not_found/index.js b/app/javascript/flavours/glitch/features/generic_not_found/index.js
new file mode 100644
index 000000000..4412adaed
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/generic_not_found/index.js
@@ -0,0 +1,11 @@
+import React from 'react';
+import Column from 'flavours/glitch/features/ui/components/column';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+
+const GenericNotFound = () => (
+  <Column>
+    <MissingIndicator fullPage />
+  </Column>
+);
+
+export default GenericNotFound;
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/announcements.js b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
new file mode 100644
index 000000000..acaa78fe3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/announcements.js
@@ -0,0 +1,455 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Icon from 'flavours/glitch/components/icon';
+import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
+import { autoPlayGif, reduceMotion } from 'flavours/glitch/util/initial_state';
+import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
+import { mascot } from 'flavours/glitch/util/initial_state';
+import unicodeMapping from 'flavours/glitch/util/emoji/emoji_unicode_mapping_light';
+import classNames from 'classnames';
+import EmojiPickerDropdown from 'flavours/glitch/features/emoji_picker';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+import TransitionMotion from 'react-motion/lib/TransitionMotion';
+import spring from 'react-motion/lib/spring';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+class Content extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+  };
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  componentDidMount () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  componentDidUpdate () {
+    this._updateLinks();
+    this._updateEmojis();
+  }
+
+  _updateEmojis () {
+    const node = this.node;
+
+    if (!node || autoPlayGif) {
+      return;
+    }
+
+    const emojis = node.querySelectorAll('.custom-emoji');
+
+    for (var i = 0; i < emojis.length; i++) {
+      let emoji = emojis[i];
+
+      if (emoji.classList.contains('status-emoji')) {
+        continue;
+      }
+
+      emoji.classList.add('status-emoji');
+
+      emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
+      emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
+    }
+  }
+
+  _updateLinks () {
+    const node = this.node;
+
+    if (!node) {
+      return;
+    }
+
+    const links = node.querySelectorAll('a');
+
+    for (var i = 0; i < links.length; ++i) {
+      let link = links[i];
+
+      if (link.classList.contains('status-link')) {
+        continue;
+      }
+
+      link.classList.add('status-link');
+
+      let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
+
+      if (mention) {
+        link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
+        link.setAttribute('title', mention.get('acct'));
+      } else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
+        link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
+      } else {
+        let status = this.props.announcement.get('statuses').find(item => link.href === item.get('url'));
+        if (status) {
+          link.addEventListener('click', this.onStatusClick.bind(this, status), false);
+        }
+        link.setAttribute('title', link.href);
+        link.classList.add('unhandled-link');
+      }
+
+      link.setAttribute('target', '_blank');
+      link.setAttribute('rel', 'noopener noreferrer');
+    }
+  }
+
+  onMentionClick = (mention, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/accounts/${mention.get('id')}`);
+    }
+  }
+
+  onHashtagClick = (hashtag, e) => {
+    hashtag = hashtag.replace(/^#/, '');
+
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/timelines/tag/${hashtag}`);
+    }
+  }
+
+  onStatusClick = (status, e) => {
+    if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${status.get('id')}`);
+    }
+  }
+
+  handleEmojiMouseEnter = ({ target }) => {
+    target.src = target.getAttribute('data-original');
+  }
+
+  handleEmojiMouseLeave = ({ target }) => {
+    target.src = target.getAttribute('data-static');
+  }
+
+  render () {
+    const { announcement } = this.props;
+
+    return (
+      <div
+        className='announcements__item__content'
+        ref={this.setRef}
+        dangerouslySetInnerHTML={{ __html: announcement.get('contentHtml') }}
+      />
+    );
+  }
+
+}
+
+const assetHost = process.env.CDN_HOST || '';
+
+class Emoji extends React.PureComponent {
+
+  static propTypes = {
+    emoji: PropTypes.string.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    hovered: PropTypes.bool.isRequired,
+  };
+
+  render () {
+    const { emoji, emojiMap, hovered } = this.props;
+
+    if (unicodeMapping[emoji]) {
+      const { filename, shortCode } = unicodeMapping[this.props.emoji];
+      const title = shortCode ? `:${shortCode}:` : '';
+
+      return (
+        <img
+          draggable='false'
+          className='emojione'
+          alt={emoji}
+          title={title}
+          src={`${assetHost}/emoji/${filename}.svg`}
+        />
+      );
+    } else if (emojiMap.get(emoji)) {
+      const filename  = (autoPlayGif || hovered) ? emojiMap.getIn([emoji, 'url']) : emojiMap.getIn([emoji, 'static_url']);
+      const shortCode = `:${emoji}:`;
+
+      return (
+        <img
+          draggable='false'
+          className='emojione custom-emoji'
+          alt={shortCode}
+          title={shortCode}
+          src={filename}
+        />
+      );
+    } else {
+      return null;
+    }
+  }
+
+}
+
+class Reaction extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reaction: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    style: PropTypes.object,
+  };
+
+  state = {
+    hovered: false,
+  };
+
+  handleClick = () => {
+    const { reaction, announcementId, addReaction, removeReaction } = this.props;
+
+    if (reaction.get('me')) {
+      removeReaction(announcementId, reaction.get('name'));
+    } else {
+      addReaction(announcementId, reaction.get('name'));
+    }
+  }
+
+  handleMouseEnter = () => this.setState({ hovered: true })
+
+  handleMouseLeave = () => this.setState({ hovered: false })
+
+  render () {
+    const { reaction } = this.props;
+
+    let shortCode = reaction.get('name');
+
+    if (unicodeMapping[shortCode]) {
+      shortCode = unicodeMapping[shortCode].shortCode;
+    }
+
+    return (
+      <button className={classNames('reactions-bar__item', { active: reaction.get('me') })} onClick={this.handleClick} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} title={`:${shortCode}:`} style={this.props.style}>
+        <span className='reactions-bar__item__emoji'><Emoji hovered={this.state.hovered} emoji={reaction.get('name')} emojiMap={this.props.emojiMap} /></span>
+        <span className='reactions-bar__item__count'><AnimatedNumber value={reaction.get('count')} /></span>
+      </button>
+    );
+  }
+
+}
+
+class ReactionsBar extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcementId: PropTypes.string.isRequired,
+    reactions: ImmutablePropTypes.list.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleEmojiPick = data => {
+    const { addReaction, announcementId } = this.props;
+    addReaction(announcementId, data.native.replace(/:/g, ''));
+  }
+
+  willEnter () {
+    return { scale: reduceMotion ? 1 : 0 };
+  }
+
+  willLeave () {
+    return { scale: reduceMotion ? 0 : spring(0, { stiffness: 170, damping: 26 }) };
+  }
+
+  render () {
+    const { reactions } = this.props;
+    const visibleReactions = reactions.filter(x => x.get('count') > 0);
+
+    const styles = visibleReactions.map(reaction => ({
+      key: reaction.get('name'),
+      data: reaction,
+      style: { scale: reduceMotion ? 1 : spring(1, { stiffness: 150, damping: 13 }) },
+    })).toArray();
+
+    return (
+      <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
+        {items => (
+          <div className={classNames('reactions-bar', { 'reactions-bar--empty': visibleReactions.isEmpty() })}>
+            {items.map(({ key, data, style }) => (
+              <Reaction
+                key={key}
+                reaction={data}
+                style={{ transform: `scale(${style.scale})`, position: style.scale < 0.5 ? 'absolute' : 'static' }}
+                announcementId={this.props.announcementId}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                emojiMap={this.props.emojiMap}
+              />
+            ))}
+
+            {visibleReactions.size < 8 && <EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} button={<Icon id='plus' />} />}
+          </div>
+        )}
+      </TransitionMotion>
+    );
+  }
+
+}
+
+class Announcement extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcement: ImmutablePropTypes.map.isRequired,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    selected: PropTypes.bool,
+  };
+
+  state = {
+    unread: !this.props.announcement.get('read'),
+  };
+
+  componentDidUpdate () {
+    const { selected, announcement } = this.props;
+    if (!selected && this.state.unread !== !announcement.get('read')) {
+      this.setState({ unread: !announcement.get('read') });
+    }
+  }
+
+  render () {
+    const { announcement } = this.props;
+    const { unread } = this.state;
+    const startsAt = announcement.get('starts_at') && new Date(announcement.get('starts_at'));
+    const endsAt = announcement.get('ends_at') && new Date(announcement.get('ends_at'));
+    const now = new Date();
+    const hasTimeRange = startsAt && endsAt;
+    const skipYear = hasTimeRange && startsAt.getFullYear() === endsAt.getFullYear() && endsAt.getFullYear() === now.getFullYear();
+    const skipEndDate = hasTimeRange && startsAt.getDate() === endsAt.getDate() && startsAt.getMonth() === endsAt.getMonth() && startsAt.getFullYear() === endsAt.getFullYear();
+    const skipTime = announcement.get('all_day');
+
+    return (
+      <div className='announcements__item'>
+        <strong className='announcements__item__range'>
+          <FormattedMessage id='announcement.announcement' defaultMessage='Announcement' />
+          {hasTimeRange && <span> · <FormattedDate value={startsAt} hour12={false} year={(skipYear || startsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month='short' day='2-digit' hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /> - <FormattedDate value={endsAt} hour12={false} year={(skipYear || endsAt.getFullYear() === now.getFullYear()) ? undefined : 'numeric'} month={skipEndDate ? undefined : 'short'} day={skipEndDate ? undefined : '2-digit'} hour={skipTime ? undefined : '2-digit'} minute={skipTime ? undefined : '2-digit'} /></span>}
+        </strong>
+
+        <Content announcement={announcement} />
+
+        <ReactionsBar
+          reactions={announcement.get('reactions')}
+          announcementId={announcement.get('id')}
+          addReaction={this.props.addReaction}
+          removeReaction={this.props.removeReaction}
+          emojiMap={this.props.emojiMap}
+        />
+
+        {unread && <span className='announcements__item__unread' />}
+      </div>
+    );
+  }
+
+}
+
+export default @injectIntl
+class Announcements extends ImmutablePureComponent {
+
+  static propTypes = {
+    announcements: ImmutablePropTypes.list,
+    emojiMap: ImmutablePropTypes.map.isRequired,
+    dismissAnnouncement: PropTypes.func.isRequired,
+    addReaction: PropTypes.func.isRequired,
+    removeReaction: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: 0,
+  };
+
+  static getDerivedStateFromProps(props, state) {
+    if (props.announcements.size > 0 && state.index >= props.announcements.size) {
+      return { index: props.announcements.size - 1 };
+    } else {
+      return null;
+    }
+  }
+
+  componentDidMount () {
+    this._markAnnouncementAsRead();
+  }
+
+  componentDidUpdate () {
+    this._markAnnouncementAsRead();
+  }
+
+  _markAnnouncementAsRead () {
+    const { dismissAnnouncement, announcements } = this.props;
+    const { index } = this.state;
+    const announcement = announcements.get(index);
+    if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
+  }
+
+  handleChangeIndex = index => {
+    this.setState({ index: index % this.props.announcements.size });
+  }
+
+  handleNextClick = () => {
+    this.setState({ index: (this.state.index + 1) % this.props.announcements.size });
+  }
+
+  handlePrevClick = () => {
+    this.setState({ index: (this.props.announcements.size + this.state.index - 1) % this.props.announcements.size });
+  }
+
+  render () {
+    const { announcements, intl } = this.props;
+    const { index } = this.state;
+
+    if (announcements.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='announcements'>
+        <img className='announcements__mastodon' alt='' draggable='false' src={mascot || elephantUIPlane} />
+
+        <div className='announcements__container'>
+          <ReactSwipeableViews animateHeight={!reduceMotion} adjustHeight={reduceMotion} index={index} onChangeIndex={this.handleChangeIndex}>
+            {announcements.map((announcement, idx) => (
+              <Announcement
+                key={announcement.get('id')}
+                announcement={announcement}
+                emojiMap={this.props.emojiMap}
+                addReaction={this.props.addReaction}
+                removeReaction={this.props.removeReaction}
+                intl={intl}
+                selected={index === idx}
+              />
+            ))}
+          </ReactSwipeableViews>
+
+          {announcements.size > 1 && (
+            <div className='announcements__pagination'>
+              <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.previous)} icon='chevron-left' onClick={this.handlePrevClick} size={13} />
+              <span>{index + 1} / {announcements.size}</span>
+              <IconButton disabled={announcements.size === 1} title={intl.formatMessage(messages.next)} icon='chevron-right' onClick={this.handleNextClick} size={13} />
+            </div>
+          )}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started/components/trends.js b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
new file mode 100644
index 000000000..0734ec72b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/components/trends.js
@@ -0,0 +1,46 @@
+import React from 'react';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Hashtag from 'flavours/glitch/components/hashtag';
+import { FormattedMessage } from 'react-intl';
+
+export default class Trends extends ImmutablePureComponent {
+
+  static defaultProps = {
+    loading: false,
+  };
+
+  static propTypes = {
+    trends: ImmutablePropTypes.list,
+    fetchTrends: PropTypes.func.isRequired,
+  };
+
+  componentDidMount () {
+    this.props.fetchTrends();
+    this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
+  }
+
+  componentWillUnmount () {
+    if (this.refreshInterval) {
+      clearInterval(this.refreshInterval);
+    }
+  }
+
+  render () {
+    const { trends } = this.props;
+
+    if (!trends || trends.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div className='getting-started__trends'>
+        <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
+
+        {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
new file mode 100644
index 000000000..d472323f8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/announcements_container.js
@@ -0,0 +1,20 @@
+import { connect } from 'react-redux';
+import { addReaction, removeReaction, dismissAnnouncement } from 'flavours/glitch/actions/announcements';
+import Announcements from '../components/announcements';
+import { createSelector } from 'reselect';
+import { Map as ImmutableMap } from 'immutable';
+
+const customEmojiMap = createSelector([state => state.get('custom_emojis')], items => items.reduce((map, emoji) => map.set(emoji.get('shortcode'), emoji), ImmutableMap()));
+
+const mapStateToProps = state => ({
+  announcements: state.getIn(['announcements', 'items']),
+  emojiMap: customEmojiMap(state),
+});
+
+const mapDispatchToProps = dispatch => ({
+  dismissAnnouncement: id => dispatch(dismissAnnouncement(id)),
+  addReaction: (id, name) => dispatch(addReaction(id, name)),
+  removeReaction: (id, name) => dispatch(removeReaction(id, name)),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Announcements);
diff --git a/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
new file mode 100644
index 000000000..68568d169
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { fetchTrends } from 'flavours/glitch/actions/trends';
+import Trends from '../components/trends';
+
+const mapStateToProps = state => ({
+  trends: state.getIn(['trends', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  fetchTrends: () => dispatch(fetchTrends()),
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(Trends);
diff --git a/app/javascript/flavours/glitch/features/getting_started/index.js b/app/javascript/flavours/glitch/features/getting_started/index.js
new file mode 100644
index 000000000..d8a51c689
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started/index.js
@@ -0,0 +1,190 @@
+import React from 'react';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { connect } from 'react-redux';
+import { openModal } from 'flavours/glitch/actions/modal';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { me, profile_directory, showTrends } from 'flavours/glitch/util/initial_state';
+import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
+import { List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import { preferencesLink } from 'flavours/glitch/util/backend_links';
+import NavigationBar from '../compose/components/navigation_bar';
+import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
+import TrendsContainer from './containers/trends_container';
+
+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' },
+  bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
+  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' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
+  lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
+  lists_subheading: { id: 'column_subheading.lists', defaultMessage: 'Lists' },
+  misc: { id: 'navigation_bar.misc', defaultMessage: 'Misc' },
+  menu: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
+  profile_directory: { id: 'getting_started.directory', defaultMessage: 'Profile directory' },
+});
+
+const makeMapStateToProps = () => {
+  const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+    if (!lists) {
+      return lists;
+    }
+
+    return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+  });
+
+  const mapStateToProps = state => ({
+    lists: getOrderedLists(state),
+    myAccount: state.getIn(['accounts', me]),
+    columns: state.getIn(['settings', 'columns']),
+    unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+    unreadNotifications: state.getIn(['notifications', 'unread']),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => ({
+  fetchFollowRequests: () => dispatch(fetchFollowRequests()),
+  fetchLists: () => dispatch(fetchLists()),
+  openSettings: () => dispatch(openModal('SETTINGS', {})),
+});
+
+const badgeDisplay = (number, limit) => {
+  if (number === 0) {
+    return undefined;
+  } else if (limit && number >= limit) {
+    return `${limit}+`;
+  } else {
+    return number;
+  }
+};
+
+const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
+
+ export default @connect(makeMapStateToProps, mapDispatchToProps)
+ @injectIntl
+ class GettingStarted extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    myAccount: ImmutablePropTypes.map.isRequired,
+    columns: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    fetchFollowRequests: PropTypes.func.isRequired,
+    unreadFollowRequests: PropTypes.number,
+    unreadNotifications: PropTypes.number,
+    lists: ImmutablePropTypes.list,
+    fetchLists: PropTypes.func.isRequired,
+    openSettings: PropTypes.func.isRequired,
+  };
+
+  componentWillMount () {
+    this.props.fetchLists();
+  }
+
+  componentDidMount () {
+    const { fetchFollowRequests, multiColumn } = this.props;
+
+    if (!multiColumn && window.innerWidth >= NAVIGATION_PANEL_BREAKPOINT) {
+      this.context.router.history.replace('/timelines/home');
+      return;
+    }
+
+    fetchFollowRequests();
+  }
+
+  render () {
+    const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications, lists, openSettings } = this.props;
+
+    const navItems = [];
+    let listItems = [];
+
+    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)} badge={badgeDisplay(unreadNotifications)} 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' />);
+    }
+
+    if (!multiColumn || !columns.find(item => item.get('id') === 'BOOKMARKS')) {
+      navItems.push(<ColumnLink key='5' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} to='/bookmarks' />);
+    }
+
+    if (myAccount.get('locked') || unreadFollowRequests > 0) {
+      navItems.push(<ColumnLink key='6' icon='user-plus' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
+    }
+
+    if (profile_directory) {
+      navItems.push(<ColumnLink key='7' icon='address-book' text={intl.formatMessage(messages.profile_directory)} to='/directory' />);
+    }
+
+    navItems.push(<ColumnLink key='8' icon='ellipsis-h' text={intl.formatMessage(messages.misc)} to='/getting-started-misc' />);
+
+    listItems = listItems.concat([
+      <div key='9'>
+        <ColumnLink key='10' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
+        {lists.map(list =>
+          <ColumnLink key={(11 + Number(list.get('id'))).toString()} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />
+        )}
+      </div>,
+    ]);
+
+    return (
+      <Column bindToDocument={!multiColumn} name='getting-started' icon='asterisk' heading={intl.formatMessage(messages.heading)} label={intl.formatMessage(messages.menu)} hideHeadingOnMobile>
+        <div className='scrollable optionally-scrollable'>
+          <div className='getting-started__wrapper'>
+            {!multiColumn && <NavigationBar account={myAccount} />}
+            {multiColumn && <ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />}
+            {navItems}
+            <ColumnSubheading text={intl.formatMessage(messages.lists_subheading)} />
+            {listItems}
+            <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
+            { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
+            <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} />
+          </div>
+
+          <LinkFooter />
+        </div>
+
+        {multiColumn && showTrends && <TrendsContainer />}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/getting_started_misc/index.js b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
new file mode 100644
index 000000000..570fe78bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/getting_started_misc/index.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { connect } from 'react-redux';
+
+const messages = defineMessages({
+  heading: { id: 'column.heading', defaultMessage: 'Misc' },
+  subheading: { id: 'column.subheading', defaultMessage: 'Miscellaneous options' },
+  favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
+  blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
+  domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
+  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' },
+  info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
+  keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
+  featured_users: { id: 'navigation_bar.featured_users', defaultMessage: 'Featured users' },
+});
+
+export default @connect()
+@injectIntl
+class gettingStartedMisc extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+  };
+
+  openOnboardingModal = (e) => {
+    this.props.dispatch(openModal('ONBOARDING'));
+  }
+
+  openFeaturedAccountsModal = (e) => {
+    this.props.dispatch(openModal('PINNED_ACCOUNTS_EDITOR'));
+  }
+
+  render () {
+    const { intl } = this.props;
+
+    let i = 1;
+
+    return (
+      <Column icon='ellipsis-h' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <div className='scrollable'>
+          <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+          <ColumnLink key='{i++}' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />
+          <ColumnLink key='{i++}' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
+          <ColumnLink key='{i++}' icon='users' text={intl.formatMessage(messages.featured_users)} onClick={this.openFeaturedAccountsModal} />
+          <ColumnLink key='{i++}' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
+          <ColumnLink key='{i++}' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
+          <ColumnLink key='{i++}' icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
+          <ColumnLink key='{i++}' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />
+          <ColumnLink key='{i++}' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />
+          <ColumnLink key='{i++}' icon='hand-o-right' text={intl.formatMessage(messages.show_me_around)} onClick={this.openOnboardingModal} />
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
new file mode 100644
index 000000000..956f16734
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/components/column_settings.js
@@ -0,0 +1,120 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Toggle from 'react-toggle';
+import AsyncSelect from 'react-select/async';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+const messages = defineMessages({
+  placeholder: { id: 'hashtag.column_settings.select.placeholder', defaultMessage: 'Enter hashtags…' },
+  noOptions: { id: 'hashtag.column_settings.select.no_options_message', defaultMessage: 'No suggestions found' },
+});
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onLoad: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    open: this.hasTags(),
+  };
+
+  hasTags () {
+    return ['all', 'any', 'none'].map(mode => this.tags(mode).length > 0).includes(true);
+  }
+
+  tags (mode) {
+    let tags = this.props.settings.getIn(['tags', mode]) || [];
+
+    if (tags.toJSON) {
+      return tags.toJSON();
+    } else {
+      return tags;
+    }
+  };
+
+  onSelect = mode => value => this.props.onChange(['tags', mode], value);
+
+  onToggle = () => {
+    if (this.state.open && this.hasTags()) {
+      this.props.onChange('tags', {});
+    }
+
+    this.setState({ open: !this.state.open });
+  };
+
+  noOptionsMessage = () => this.props.intl.formatMessage(messages.noOptions);
+
+  modeSelect (mode) {
+    return (
+      <div className='column-settings__row'>
+        <span className='column-settings__section'>
+          {this.modeLabel(mode)}
+        </span>
+
+        <AsyncSelect
+          isMulti
+          autoFocus
+          value={this.tags(mode)}
+          onChange={this.onSelect(mode)}
+          loadOptions={this.props.onLoad}
+          className='column-select__container'
+          classNamePrefix='column-select'
+          name='tags'
+          placeholder={this.props.intl.formatMessage(messages.placeholder)}
+          noOptionsMessage={this.noOptionsMessage}
+        />
+      </div>
+    );
+  }
+
+  modeLabel (mode) {
+    switch(mode) {
+    case 'any':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.any' defaultMessage='Any of these' />;
+    case 'all':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.all' defaultMessage='All of these' />;
+    case 'none':
+      return <FormattedMessage id='hashtag.column_settings.tag_mode.none' defaultMessage='None of these' />;
+    default:
+      return '';
+    }
+  };
+
+  render () {
+    const { settings, onChange } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <div className='setting-toggle'>
+            <Toggle id='hashtag.column_settings.tag_toggle' onChange={this.onToggle} checked={this.state.open} />
+
+            <span className='setting-toggle__label'>
+              <FormattedMessage id='hashtag.column_settings.tag_toggle' defaultMessage='Include additional tags in this column' />
+            </span>
+          </div>
+        </div>
+
+        {this.state.open && (
+          <div className='column-settings__hashtags'>
+            {this.modeSelect('any')}
+            {this.modeSelect('all')}
+            {this.modeSelect('none')}
+          </div>
+        )}
+
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['local']} onChange={onChange} label={<FormattedMessage id='community.column_settings.local_only' defaultMessage='Local only' />} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..de1db692d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/containers/column_settings_container.js
@@ -0,0 +1,31 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+import api from 'flavours/glitch/util/api';
+
+const mapStateToProps = (state, { columnId }) => {
+  const columns = state.getIn(['settings', 'columns']);
+  const index   = columns.findIndex(c => c.get('uuid') === columnId);
+
+  if (!(columnId && index >= 0)) {
+    return {};
+  }
+
+  return { settings: columns.get(index).get('params') };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => ({
+  onChange (key, value) {
+    dispatch(changeColumnParams(columnId, key, value));
+  },
+
+  onLoad (value) {
+    return api().get('/api/v2/search', { params: { q: value, type: 'hashtags' } }).then(response => {
+      return (response.data.hashtags || []).map((tag) => {
+        return { value: tag.name, label: `#${tag.name}` };
+      });
+    });
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
new file mode 100644
index 000000000..48e52e4cd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/hashtag_timeline/index.js
@@ -0,0 +1,165 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { expandHashtagTimeline, clearTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { FormattedMessage } from 'react-intl';
+import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
+import { isEqual } from 'lodash';
+
+const mapStateToProps = (state, props) => ({
+  hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
+});
+
+export default @connect(mapStateToProps)
+class HashtagTimeline extends React.PureComponent {
+
+  disconnects = [];
+
+  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 }));
+    }
+  }
+
+  title = () => {
+    let title = [this.props.params.id];
+
+    if (this.additionalFor('any')) {
+      title.push(' ', <FormattedMessage key='any' id='hashtag.column_header.tag_mode.any'  values={{ additional: this.additionalFor('any') }} defaultMessage='or {additional}' />);
+    }
+
+    if (this.additionalFor('all')) {
+      title.push(' ', <FormattedMessage key='all' id='hashtag.column_header.tag_mode.all'  values={{ additional: this.additionalFor('all') }} defaultMessage='and {additional}' />);
+    }
+
+    if (this.additionalFor('none')) {
+      title.push(' ', <FormattedMessage key='none' id='hashtag.column_header.tag_mode.none' values={{ additional: this.additionalFor('none') }} defaultMessage='without {additional}' />);
+    }
+
+    return title;
+  }
+
+  additionalFor = (mode) => {
+    const { tags } = this.props.params;
+
+    if (tags && (tags[mode] || []).length > 0) {
+      return tags[mode].map(tag => tag.value).join('/');
+    } else {
+      return '';
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  _subscribe (dispatch, id, tags = {}, local) {
+    let any  = (tags.any || []).map(tag => tag.value);
+    let all  = (tags.all || []).map(tag => tag.value);
+    let none = (tags.none || []).map(tag => tag.value);
+
+    [id, ...any].map(tag => {
+      this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
+        let tags = status.tags.map(tag => tag.name);
+
+        return all.filter(tag => tags.includes(tag)).length === all.length &&
+               none.filter(tag => tags.includes(tag)).length === 0;
+      })));
+    });
+  }
+
+  _unsubscribe () {
+    this.disconnects.map(disconnect => disconnect());
+    this.disconnects = [];
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id, tags, local } = this.props.params;
+
+    this._subscribe(dispatch, id, tags, local);
+    dispatch(expandHashtagTimeline(id, { tags, local }));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    const { dispatch, params } = this.props;
+    const { id, tags, local } = nextProps.params;
+
+    if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
+      this._unsubscribe();
+      this._subscribe(dispatch, id, tags, local);
+      dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
+      dispatch(expandHashtagTimeline(id, { tags, local }));
+    }
+  }
+
+  componentWillUnmount () {
+    this._unsubscribe();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = maxId => {
+    const { id, tags, local } = this.props.params;
+    this.props.dispatch(expandHashtagTimeline(id, { maxId, tags, local }));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn } = this.props;
+    const { id,  local } = this.props.params;
+    const pinned = !!columnId;
+
+    return (
+      <Column ref={this.setRef} name='hashtag' label={`#${id}`}>
+        <ColumnHeader
+          icon='hashtag'
+          active={hasUnread}
+          title={this.title()}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          showBackButton
+          bindToDocument={!multiColumn}
+        >
+          {columnId && <ColumnSettingsContainer columnId={columnId} />}
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`hashtag_timeline-${columnId}`}
+          timelineId={`hashtag:${id}${local ? ':local' : ''}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.hashtag' defaultMessage='There is nothing in this hashtag yet.' />}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js
new file mode 100644
index 000000000..df615db65
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/home_timeline/components/column_settings.js
@@ -0,0 +1,50 @@
+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 'flavours/glitch/features/notifications/components/setting_toggle';
+import SettingText from 'flavours/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' },
+});
+
+export default @injectIntl
+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} settingPath={['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} settingPath={['shows', 'reply']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_replies' defaultMessage='Show replies' />} />
+        </div>
+
+        <div className='column-settings__row'>
+          <SettingToggle prefix='home_timeline' settings={settings} settingPath={['shows', 'direct']} onChange={onChange} label={<FormattedMessage id='home.column_settings.show_direct' defaultMessage='Show DMs' />} />
+        </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} settingPath={['regex', 'body']} onChange={onChange} label={intl.formatMessage(messages.filter_regex)} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/home_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..16747151b
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/glitch/actions/settings';
+
+const mapStateToProps = state => ({
+  settings: state.getIn(['settings', 'home']),
+});
+
+const mapDispatchToProps = dispatch => ({
+
+  onChange (path, checked) {
+    dispatch(changeSetting(['home', ...path], checked));
+  },
+
+  onSave () {
+    dispatch(saveSettings());
+  },
+
+});
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/home_timeline/index.js b/app/javascript/flavours/glitch/features/home_timeline/index.js
new file mode 100644
index 000000000..cc8e4664c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/home_timeline/index.js
@@ -0,0 +1,162 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { Link } from 'react-router-dom';
+import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
+import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
+import classNames from 'classnames';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+
+const messages = defineMessages({
+  title: { id: 'column.home', defaultMessage: 'Home' },
+  show_announcements: { id: 'home.show_announcements', defaultMessage: 'Show announcements' },
+  hide_announcements: { id: 'home.hide_announcements', defaultMessage: 'Hide announcements' },
+});
+
+const mapStateToProps = state => ({
+  hasUnread: state.getIn(['timelines', 'home', 'unread']) > 0,
+  isPartial: state.getIn(['timelines', 'home', 'isPartial']),
+  hasAnnouncements: !state.getIn(['announcements', 'items']).isEmpty(),
+  unreadAnnouncements: state.getIn(['announcements', 'items']).count(item => !item.get('read')),
+  showAnnouncements: state.getIn(['announcements', 'show']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class HomeTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasUnread: PropTypes.bool,
+    isPartial: PropTypes.bool,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasAnnouncements: PropTypes.bool,
+    unreadAnnouncements: PropTypes.number,
+    showAnnouncements: 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 = maxId => {
+    this.props.dispatch(expandHomeTimeline({ maxId }));
+  }
+
+  componentDidMount () {
+    this.props.dispatch(fetchAnnouncements());
+    this._checkIfReloadNeeded(false, this.props.isPartial);
+  }
+
+  componentDidUpdate (prevProps) {
+    this._checkIfReloadNeeded(prevProps.isPartial, this.props.isPartial);
+  }
+
+  componentWillUnmount () {
+    this._stopPolling();
+  }
+
+  _checkIfReloadNeeded (wasPartial, isPartial) {
+    const { dispatch } = this.props;
+
+    if (wasPartial === isPartial) {
+      return;
+    } else if (!wasPartial && isPartial) {
+      this.polling = setInterval(() => {
+        dispatch(expandHomeTimeline());
+      }, 3000);
+    } else if (wasPartial && !isPartial) {
+      this._stopPolling();
+    }
+  }
+
+  _stopPolling () {
+    if (this.polling) {
+      clearInterval(this.polling);
+      this.polling = null;
+    }
+  }
+
+  handleToggleAnnouncementsClick = (e) => {
+    e.stopPropagation();
+    this.props.dispatch(toggleShowAnnouncements());
+  }
+
+  render () {
+    const { intl, hasUnread, columnId, multiColumn, hasAnnouncements, unreadAnnouncements, showAnnouncements } = this.props;
+    const pinned = !!columnId;
+
+    let announcementsButton = null;
+
+    if (hasAnnouncements) {
+      announcementsButton = (
+        <button
+          className={classNames('column-header__button', { 'active': showAnnouncements })}
+          title={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
+          aria-label={intl.formatMessage(showAnnouncements ? messages.hide_announcements : messages.show_announcements)}
+          aria-pressed={showAnnouncements ? 'true' : 'false'}
+          onClick={this.handleToggleAnnouncementsClick}
+        >
+          <IconWithBadge id='bullhorn' count={unreadAnnouncements} />
+        </button>
+      );
+    }
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='home' label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='home'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          extraButton={announcementsButton}
+          appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`home_timeline-${columnId}`}
+          onLoadMore={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> }} />}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
new file mode 100644
index 000000000..abc3f468f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
+});
+
+const mapStateToProps = state => ({
+  collapseEnabled: state.getIn(['local_settings', 'collapsed', 'enabled']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class KeyboardShortcuts extends ImmutablePureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+    collapseEnabled: PropTypes.bool,
+  };
+
+  render () {
+    const { intl, collapseEnabled, multiColumn } = this.props;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='question' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <div className='keyboard-shortcuts scrollable optionally-scrollable'>
+          <table>
+            <thead>
+              <tr>
+                <th><FormattedMessage id='keyboard_shortcuts.hotkey' defaultMessage='Hotkey' /></th>
+                <th><FormattedMessage id='keyboard_shortcuts.description' defaultMessage='Description' /></th>
+              </tr>
+            </thead>
+            <tbody>
+              <tr>
+                <td><kbd>r</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.reply' defaultMessage='to reply' /></td>
+              </tr>
+              <tr>
+                <td><kbd>m</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.mention' defaultMessage='to mention author' /></td>
+              </tr>
+              <tr>
+                <td><kbd>p</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.profile' defaultMessage="to open author's profile" /></td>
+              </tr>
+              <tr>
+                <td><kbd>f</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.favourite' defaultMessage='to favourite' /></td>
+              </tr>
+              <tr>
+                <td><kbd>b</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
+              </tr>
+              <tr>
+                <td><kbd>d</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.bookmark' defaultMessage='to bookmark' /></td>
+              </tr>
+              <tr>
+                <td><kbd>enter</kbd>, <kbd>o</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
+              </tr>
+              <tr>
+                <td><kbd>e</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
+              </tr>
+              <tr>
+                <td><kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
+              </tr>
+              <tr>
+                <td><kbd>h</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
+              </tr>
+              {collapseEnabled && (
+                <tr>
+                  <td><kbd>shift</kbd>+<kbd>x</kbd></td>
+                  <td><FormattedMessage id='keyboard_shortcuts.toggle_collapse' defaultMessage='to collapse/uncollapse toots' /></td>
+                </tr>
+              )}
+              <tr>
+                <td><kbd>up</kbd>, <kbd>k</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
+              </tr>
+              <tr>
+                <td><kbd>down</kbd>, <kbd>j</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.down' defaultMessage='to move down in the list' /></td>
+              </tr>
+              <tr>
+                <td><kbd>1</kbd>-<kbd>9</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.column' defaultMessage='to focus a status in one of the columns' /></td>
+              </tr>
+              <tr>
+                <td><kbd>n</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.compose' defaultMessage='to focus the compose textarea' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>n</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.toot' defaultMessage='to start a brand new toot' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>x</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.spoilers' defaultMessage='to show/hide CW field' /></td>
+              </tr>
+              <tr>
+                <td><kbd>backspace</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.back' defaultMessage='to navigate back' /></td>
+              </tr>
+              <tr>
+                <td><kbd>s</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.search' defaultMessage='to focus search' /></td>
+              </tr>
+              <tr>
+                <td><kbd>alt</kbd>+<kbd>enter</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.secondary_toot' defaultMessage='to send toot using secondary privacy setting' /></td>
+              </tr>
+              <tr>
+                <td><kbd>esc</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
+              </tr>
+              <tr>
+                <td><kbd>?</kbd></td>
+                <td><FormattedMessage id='keyboard_shortcuts.legend' defaultMessage='to display this legend' /></td>
+              </tr>
+            </tbody>
+          </table>
+        </div>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_adder/components/account.js b/app/javascript/flavours/glitch/features/list_adder/components/account.js
new file mode 100644
index 000000000..1369aac07
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/components/account.js
@@ -0,0 +1,43 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from '../../../selectors';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from '../../../components/avatar';
+import DisplayName from '../../../components/display_name';
+import { injectIntl } from 'react-intl';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId }) => ({
+    account: getAccount(state, accountId),
+  });
+
+  return mapStateToProps;
+};
+
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+  };
+
+  render () {
+    const { account } = this.props;
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <div className='account__display-name'>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <DisplayName account={account} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_adder/components/list.js b/app/javascript/flavours/glitch/features/list_adder/components/list.js
new file mode 100644
index 000000000..4666ca47b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/components/list.js
@@ -0,0 +1,69 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import IconButton from '../../../components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+import { removeFromListAdder, addToListAdder } from '../../../actions/lists';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+const MapStateToProps = (state, { listId, added }) => ({
+  list: state.get('lists').get(listId),
+  added: typeof added === 'undefined' ? state.getIn(['listAdder', 'lists', 'items']).includes(listId) : added,
+});
+
+const mapDispatchToProps = (dispatch, { listId }) => ({
+  onRemove: () => dispatch(removeFromListAdder(listId)),
+  onAdd: () => dispatch(addToListAdder(listId)),
+});
+
+export default @connect(MapStateToProps, mapDispatchToProps)
+@injectIntl
+class List extends ImmutablePureComponent {
+
+  static propTypes = {
+    list: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    onAdd: PropTypes.func.isRequired,
+    added: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    added: false,
+  };
+
+  render () {
+    const { list, intl, onRemove, onAdd, added } = this.props;
+
+    let button;
+
+    if (added) {
+      button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
+    }
+
+    return (
+      <div className='list'>
+        <div className='list__wrapper'>
+          <div className='list__display-name'>
+            <Icon id='list-ul' className='column-link__icon' fixedWidth />
+            {list.get('title')}
+          </div>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_adder/index.js b/app/javascript/flavours/glitch/features/list_adder/index.js
new file mode 100644
index 000000000..cb8a15e8c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_adder/index.js
@@ -0,0 +1,73 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListAdder, resetListAdder } from '../../actions/lists';
+import { createSelector } from 'reselect';
+import List from './components/list';
+import Account from './components/account';
+import NewListForm from '../lists/components/new_list_form';
+// hack
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+});
+
+const mapStateToProps = state => ({
+  listIds: getOrderedLists(state).map(list=>list.get('id')),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: accountId => dispatch(setupListAdder(accountId)),
+  onReset: () => dispatch(resetListAdder()),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListAdder extends ImmutablePureComponent {
+
+  static propTypes = {
+    accountId: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    listIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize, accountId } = this.props;
+    onInitialize(accountId);
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountId, listIds } = this.props;
+
+    return (
+      <div className='modal-root__modal list-adder'>
+        <div className='list-adder__account'>
+          <Account accountId={accountId} />
+        </div>
+
+        <NewListForm />
+
+
+        <div className='list-adder__lists'>
+          {listIds.map(ListId => <List key={ListId} listId={ListId} />)}
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/account.js b/app/javascript/flavours/glitch/features/list_editor/components/account.js
new file mode 100644
index 000000000..71a8b7673
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/account.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  remove: { id: 'lists.account.remove', defaultMessage: 'Remove from list' },
+  add: { id: 'lists.account.add', defaultMessage: 'Add to list' },
+});
+
+export default class Account extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    intl: PropTypes.object.isRequired,
+    onRemove: PropTypes.func.isRequired,
+    onAdd: PropTypes.func.isRequired,
+    added: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    added: false,
+  };
+
+  render () {
+    const { account, intl, onRemove, onAdd, added } = this.props;
+
+    let button;
+
+    if (added) {
+      button = <IconButton icon='times' title={intl.formatMessage(messages.remove)} onClick={onRemove} />;
+    } else {
+      button = <IconButton icon='plus' title={intl.formatMessage(messages.add)} onClick={onAdd} />;
+    }
+
+    return (
+      <div className='account'>
+        <div className='account__wrapper'>
+          <div className='account__display-name'>
+            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+            <DisplayName account={account} />
+          </div>
+
+          <div className='account__relationship'>
+            {button}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
new file mode 100644
index 000000000..a8cab2762
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/edit_list_form.js
@@ -0,0 +1,70 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  title: { id: 'lists.edit.submit', defaultMessage: 'Change title' },
+});
+
+const mapStateToProps = state => ({
+  value: state.getIn(['listEditor', 'title']),
+  disabled: !state.getIn(['listEditor', 'isChanged']) || !state.getIn(['listEditor', 'title']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onChange: value => dispatch(changeListEditorTitle(value)),
+  onSubmit: () => dispatch(submitListEditor(false)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListForm extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleSubmit = e => {
+    e.preventDefault();
+    this.props.onSubmit();
+  }
+
+  handleClick = () => {
+    this.props.onSubmit();
+  }
+
+  render () {
+    const { value, disabled, intl } = this.props;
+
+    const title = intl.formatMessage(messages.title);
+
+    return (
+      <form className='column-inline-form' onSubmit={this.handleSubmit}>
+        <input
+          className='setting-text'
+          value={value}
+          onChange={this.handleChange}
+        />
+
+        <IconButton
+          disabled={disabled}
+          icon='check'
+          title={title}
+          onClick={this.handleClick}
+        />
+      </form>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/components/search.js b/app/javascript/flavours/glitch/features/list_editor/components/search.js
new file mode 100644
index 000000000..192643f77
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/components/search.js
@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  search: { id: 'lists.search', defaultMessage: 'Search among people you follow' },
+});
+
+export default class Search extends React.PureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    value: PropTypes.string.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleKeyUp = e => {
+    if (e.keyCode === 13) {
+      this.props.onSubmit(this.props.value);
+    }
+  }
+
+  handleClear = () => {
+    this.props.onClear();
+  }
+
+  render () {
+    const { value, intl } = this.props;
+    const hasValue = value.length > 0;
+
+    return (
+      <div className='list-editor__search search'>
+        <label>
+          <span style={{ display: 'none' }}>{intl.formatMessage(messages.search)}</span>
+
+          <input
+            className='search__input'
+            type='text'
+            value={value}
+            onChange={this.handleChange}
+            onKeyUp={this.handleKeyUp}
+            placeholder={intl.formatMessage(messages.search)}
+          />
+        </label>
+
+        <div role='button' tabIndex='0' className='search__icon' onClick={this.handleClear}>
+          <Icon id='search' className={classNames({ active: !hasValue })} />
+          <Icon id='times-circle' aria-label={intl.formatMessage(messages.search)} className={classNames({ active: hasValue })} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js
new file mode 100644
index 000000000..782eb42f3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/containers/account_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { injectIntl } from 'react-intl';
+import { removeFromListEditor, addToListEditor } from 'flavours/glitch/actions/lists';
+import Account from '../components/account';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId, added }) => ({
+    account: getAccount(state, accountId),
+    added: typeof added === 'undefined' ? state.getIn(['listEditor', 'accounts', 'items']).includes(accountId) : added,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+  onRemove: () => dispatch(removeFromListEditor(accountId)),
+  onAdd: () => dispatch(addToListEditor(accountId)),
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js
new file mode 100644
index 000000000..5af20efbd
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/containers/search_container.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { injectIntl } from 'react-intl';
+import { fetchListSuggestions, clearListSuggestions, changeListSuggestions } from '../../../actions/lists';
+import Search from '../components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['listEditor', 'suggestions', 'value']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSubmit: value => dispatch(fetchListSuggestions(value)),
+  onClear: () => dispatch(clearListSuggestions()),
+  onChange: value => dispatch(changeListSuggestions(value)),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
diff --git a/app/javascript/flavours/glitch/features/list_editor/index.js b/app/javascript/flavours/glitch/features/list_editor/index.js
new file mode 100644
index 000000000..75b0de3d3
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_editor/index.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl } from 'react-intl';
+import { setupListEditor, clearListSuggestions, resetListEditor } from 'flavours/glitch/actions/lists';
+import AccountContainer from './containers/account_container';
+import SearchContainer from './containers/search_container';
+import EditListForm from './components/edit_list_form';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['listEditor', 'accounts', 'items']),
+  searchAccountIds: state.getIn(['listEditor', 'suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: listId => dispatch(setupListEditor(listId)),
+  onClear: () => dispatch(clearListSuggestions()),
+  onReset: () => dispatch(resetListEditor()),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class ListEditor extends ImmutablePureComponent {
+
+  static propTypes = {
+    listId: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    searchAccountIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize, listId } = this.props;
+    onInitialize(listId);
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountIds, searchAccountIds, onClear } = this.props;
+    const showSearch = searchAccountIds.size > 0;
+
+    return (
+      <div className='modal-root__modal list-editor'>
+        <EditListForm />
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner list-editor__accounts'>
+            {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
+          </div>
+
+          {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
+              </div>)
+            }
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
new file mode 100644
index 000000000..908a65597
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -0,0 +1,222 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { connectListStream } from 'flavours/glitch/actions/streaming';
+import { expandListTimeline } from 'flavours/glitch/actions/timelines';
+import { fetchList, deleteList, updateList } from 'flavours/glitch/actions/lists';
+import { openModal } from 'flavours/glitch/actions/modal';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
+  deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
+  all_replies:   { id: 'lists.replies_policy.all_replies', defaultMessage: 'any followed user' },
+  no_replies:    { id: 'lists.replies_policy.no_replies', defaultMessage: 'no one' },
+  list_replies:  { id: 'lists.replies_policy.list_replies', defaultMessage: 'members of the list' },
+});
+
+const mapStateToProps = (state, props) => ({
+  list: state.getIn(['lists', props.params.id]),
+  hasUnread: state.getIn(['timelines', `list:${props.params.id}`, 'unread']) > 0,
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class ListTimeline extends React.PureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    columnId: PropTypes.string,
+    hasUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    list: PropTypes.oneOfType([ImmutablePropTypes.map, PropTypes.bool]),
+    intl: PropTypes.object.isRequired,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn('LIST', { id: this.props.params.id }));
+      this.context.router.history.push('/');
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(fetchList(id));
+    dispatch(expandListTimeline(id));
+
+    this.disconnect = dispatch(connectListStream(id));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    const { dispatch } = this.props;
+    const { id } = nextProps.params;
+
+    if (id !== this.props.params.id) {
+      if (this.disconnect) {
+        this.disconnect();
+        this.disconnect = null;
+      }
+
+      dispatch(fetchList(id));
+      dispatch(expandListTimeline(id));
+
+      this.disconnect = dispatch(connectListStream(id));
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = maxId => {
+    const { id } = this.props.params;
+    this.props.dispatch(expandListTimeline(id, { maxId }));
+  }
+
+  handleEditClick = () => {
+    this.props.dispatch(openModal('LIST_EDITOR', { listId: this.props.params.id }));
+  }
+
+  handleDeleteClick = () => {
+    const { dispatch, columnId, intl } = this.props;
+    const { id } = this.props.params;
+
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.deleteMessage),
+      confirm: intl.formatMessage(messages.deleteConfirm),
+      onConfirm: () => {
+        dispatch(deleteList(id));
+
+        if (!!columnId) {
+          dispatch(removeColumn(columnId));
+        } else {
+          this.context.router.history.push('/lists');
+        }
+      },
+    }));
+  }
+
+  handleRepliesPolicyChange = ({ target }) => {
+    const { dispatch, list } = this.props;
+    const { id } = this.props.params;
+    this.props.dispatch(updateList(id, undefined, false, target.value));
+  }
+
+  render () {
+    const { hasUnread, columnId, multiColumn, list, intl } = this.props;
+    const { id } = this.props.params;
+    const pinned = !!columnId;
+    const title  = list ? list.get('title') : id;
+    const replies_policy = list ? list.get('replies_policy') : undefined;
+
+    if (typeof list === 'undefined') {
+      return (
+        <Column>
+          <div className='scrollable'>
+            <LoadingIndicator />
+          </div>
+        </Column>
+      );
+    } else if (list === false) {
+      return (
+        <Column>
+          <div className='scrollable'>
+            <MissingIndicator />
+          </div>
+        </Column>
+      );
+    }
+
+    return (
+      <Column ref={this.setRef} label={title}>
+        <ColumnHeader
+          icon='list-ul'
+          active={hasUnread}
+          title={title}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+          bindToDocument={!multiColumn}
+        >
+          <div className='column-header__links'>
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleEditClick}>
+              <Icon id='pencil' /> <FormattedMessage id='lists.edit' defaultMessage='Edit list' />
+            </button>
+
+            <button className='text-btn column-header__setting-btn' tabIndex='0' onClick={this.handleDeleteClick}>
+              <Icon id='trash' /> <FormattedMessage id='lists.delete' defaultMessage='Delete list' />
+            </button>
+          </div>
+
+          { replies_policy !== undefined && (
+            <div>
+              <div className='column-settings__row'>
+                <fieldset>
+                  <legend><FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /></legend>
+                  { ['no_replies', 'list_replies', 'all_replies'].map(policy => (
+                    <div className='setting-radio'>
+                      <input className='setting-radio__input' id={['setting', 'radio', id, policy].join('-')} type='radio' value={policy} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
+                      <label className='setting-radio__label' htmlFor={['setting', 'radio', id, policy].join('-')}>
+                        <FormattedMessage {...messages[policy]} />
+                      </label>
+                    </div>
+                  ))}
+                </fieldset>
+              </div>
+            </div>
+          )}
+
+          <hr />
+        </ColumnHeader>
+
+        <StatusListContainer
+          trackScroll={!pinned}
+          scrollKey={`list_timeline-${columnId}`}
+          timelineId={`list:${id}`}
+          onLoadMore={this.handleLoadMore}
+          emptyMessage={<FormattedMessage id='empty_column.list' defaultMessage='There is nothing in this list yet.' />}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/lists/components/new_list_form.js b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js
new file mode 100644
index 000000000..cc78d30b7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/lists/components/new_list_form.js
@@ -0,0 +1,78 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { changeListEditorTitle, submitListEditor } from 'flavours/glitch/actions/lists';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl } from 'react-intl';
+
+const messages = defineMessages({
+  label: { id: 'lists.new.title_placeholder', defaultMessage: 'New list title' },
+  title: { id: 'lists.new.create', defaultMessage: 'Add list' },
+});
+
+const mapStateToProps = state => ({
+  value: state.getIn(['listEditor', 'title']),
+  disabled: state.getIn(['listEditor', 'isSubmitting']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onChange: value => dispatch(changeListEditorTitle(value)),
+  onSubmit: () => dispatch(submitListEditor(true)),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class NewListForm extends React.PureComponent {
+
+  static propTypes = {
+    value: PropTypes.string.isRequired,
+    disabled: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+    onChange: PropTypes.func.isRequired,
+    onSubmit: PropTypes.func.isRequired,
+  };
+
+  handleChange = e => {
+    this.props.onChange(e.target.value);
+  }
+
+  handleSubmit = e => {
+    e.preventDefault();
+    this.props.onSubmit();
+  }
+
+  handleClick = () => {
+    this.props.onSubmit();
+  }
+
+  render () {
+    const { value, disabled, intl } = this.props;
+
+    const label = intl.formatMessage(messages.label);
+    const title = intl.formatMessage(messages.title);
+
+    return (
+      <form className='column-inline-form' onSubmit={this.handleSubmit}>
+        <label>
+          <span style={{ display: 'none' }}>{label}</span>
+
+          <input
+            className='setting-text'
+            value={value}
+            disabled={disabled}
+            onChange={this.handleChange}
+            placeholder={label}
+          />
+        </label>
+
+        <IconButton
+          disabled={disabled || !value}
+          icon='plus'
+          title={title}
+          onClick={this.handleClick}
+        />
+      </form>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/lists/index.js b/app/javascript/flavours/glitch/features/lists/index.js
new file mode 100644
index 000000000..e384f301b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/lists/index.js
@@ -0,0 +1,83 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ColumnLink from 'flavours/glitch/features/ui/components/column_link';
+import ColumnSubheading from 'flavours/glitch/features/ui/components/column_subheading';
+import NewListForm from './components/new_list_form';
+import { createSelector } from 'reselect';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  heading: { id: 'column.lists', defaultMessage: 'Lists' },
+  subheading: { id: 'lists.subheading', defaultMessage: 'Your lists' },
+});
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title')));
+});
+
+const mapStateToProps = state => ({
+  lists: getOrderedLists(state),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Lists extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchLists());
+  }
+
+  render () {
+    const { intl, lists, multiColumn } = this.props;
+
+    if (!lists) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.lists' defaultMessage="You don't have any lists yet. When you create one, it will show up here." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='bars' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+
+        <NewListForm />
+
+        <ColumnSubheading text={intl.formatMessage(messages.subheading)} />
+        <ScrollableList
+          scrollKey='lists'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {lists.map(list =>
+            <ColumnLink key={list.get('id')} to={`/timelines/list/${list.get('id')}`} icon='list-ul' text={list.get('title')} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/index.js b/app/javascript/flavours/glitch/features/local_settings/index.js
new file mode 100644
index 000000000..4e4605ea9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/index.js
@@ -0,0 +1,65 @@
+//  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 'flavours/glitch/actions/modal';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+
+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/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
new file mode 100644
index 000000000..ab3a554bf
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
@@ -0,0 +1,100 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, defineMessages } from 'react-intl';
+
+//  Our imports
+import LocalSettingsNavigationItem from './item';
+import { preferencesLink } from 'flavours/glitch/util/backend_links';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  general: {  id: 'settings.general', defaultMessage: 'General' },
+  compose: {  id: 'settings.compose_box_opts', defaultMessage: 'Compose box' },
+  content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' },
+  filters: { id: 'settings.filters', defaultMessage: 'Filters' },
+  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' },
+});
+
+export default @injectIntl
+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}
+          icon='cogs'
+          title={intl.formatMessage(messages.general)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 1}
+          index={1}
+          onNavigate={onNavigate}
+          icon='pencil'
+          title={intl.formatMessage(messages.compose)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 2}
+          index={2}
+          onNavigate={onNavigate}
+          textIcon='CW'
+          title={intl.formatMessage(messages.content_warnings)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 3}
+          index={3}
+          onNavigate={onNavigate}
+          icon='filter'
+          title={intl.formatMessage(messages.filters)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 4}
+          index={4}
+          onNavigate={onNavigate}
+          icon='angle-double-up'
+          title={intl.formatMessage(messages.collapsed)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 5}
+          index={5}
+          onNavigate={onNavigate}
+          icon='image'
+          title={intl.formatMessage(messages.media)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 6}
+          href={ preferencesLink }
+          index={6}
+          icon='cog'
+          title={intl.formatMessage(messages.preferences)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 7}
+          className='close'
+          index={7}
+          onNavigate={onClose}
+          icon='times'
+          title={intl.formatMessage(messages.close)}
+        />
+      </nav>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js
new file mode 100644
index 000000000..4dec7d154
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/item/index.js
@@ -0,0 +1,70 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import Icon from 'flavours/glitch/components/icon';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPage extends React.PureComponent {
+
+  static propTypes = {
+    active: PropTypes.bool,
+    className: PropTypes.string,
+    href: PropTypes.string,
+    icon: PropTypes.string,
+    textIcon: 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,
+      textIcon,
+      onNavigate,
+      title,
+    } = this.props;
+
+    const finalClassName = classNames('glitch', 'local-settings__navigation__item', {
+      active,
+    }, className);
+
+    const iconElem = icon ? <Icon fixedWidth id={icon} /> : (textIcon ? <span className='text-icon-button'>{textIcon}</span> : null);
+
+    if (href) return (
+      <a
+        href={href}
+        className={finalClassName}
+      >
+        {iconElem} <span>{title}</span>
+      </a>
+    );
+    else if (onNavigate) return (
+      <a
+        onClick={handleClick}
+        role='button'
+        tabIndex='0'
+        className={finalClassName}
+      >
+        {iconElem} <span>{title}</span>
+      </a>
+    );
+    else return null;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
new file mode 100644
index 000000000..0b3428027
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -0,0 +1,435 @@
+//  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';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+const messages = defineMessages({
+  layout_auto: {  id: 'layout.auto', defaultMessage: 'Auto' },
+  layout_auto_hint: {  id: 'layout.hint.auto', defaultMessage: 'Automatically chose layout based on “Enable advanced web interface” setting and screen size.' },
+  layout_desktop: { id: 'layout.desktop', defaultMessage: 'Desktop' },
+  layout_desktop_hint: { id: 'layout.hint.desktop', defaultMessage: 'Use multiple-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
+  layout_mobile: { id: 'layout.single', defaultMessage: 'Mobile' },
+  layout_mobile_hint: { id: 'layout.hint.single', defaultMessage: 'Use single-column layout regardless of the “Enable advanced web interface” setting or screen size.' },
+  side_arm_none: { id: 'settings.side_arm.none', defaultMessage: 'None' },
+  side_arm_keep: { id: 'settings.side_arm_reply_mode.keep', defaultMessage: 'Keep secondary toot button to set privacy' },
+  side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
+  side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' },
+  regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' },
+  filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' },
+  filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' },
+  filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' },
+  filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' },
+  rewrite_mentions_no: { id: 'settings.rewrite_mentions_no', defaultMessage: 'Do not rewrite mentions' },
+  rewrite_mentions_acct: { id: 'settings.rewrite_mentions_acct', defaultMessage: 'Rewrite with username and domain (when the account is remote)' },
+  rewrite_mentions_username: { id: 'settings.rewrite_mentions_username', defaultMessage:  'Rewrite with username' },
+});
+
+export default @injectIntl
+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={['show_reply_count']}
+          id='mastodon-settings--reply-count'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.show_reply_counter' defaultMessage='Display an estimate of the reply count' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['hicolor_privacy_icons']}
+          id='mastodon-settings--hicolor_privacy_icons'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.hicolor_privacy_icons' defaultMessage='High color privacy icons' />
+          <span className='hint'><FormattedMessage id='settings.hicolor_privacy_icons.hint' defaultMessage="Display privacy icons in bright and easily distinguishable colors" /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_boost_missing_media_description']}
+          id='mastodon-settings--confirm_boost_missing_media_description'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_boost_missing_media_description' defaultMessage='Show confirmation dialog before boosting toots lacking media descriptions' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['tag_misleading_links']}
+          id='mastodon-settings--tag_misleading_links'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.tag_misleading_links' defaultMessage='Tag misleading links' />
+          <span className='hint'><FormattedMessage id='settings.tag_misleading_links.hint' defaultMessage="Add a visual indication with the link target host to every link not mentioning it explicitly" /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['rewrite_mentions']}
+          id='mastodon-settings--rewrite_mentions'
+          options={[
+            { value: 'no', message: intl.formatMessage(messages.rewrite_mentions_no) },
+            { value: 'acct', message: intl.formatMessage(messages.rewrite_mentions_acct) },
+            { value: 'username', message: intl.formatMessage(messages.rewrite_mentions_username) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.rewrite_mentions' defaultMessage='Rewrite mentions in displayed statuses' />
+        </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.notifications_opts' defaultMessage='Notifications options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'tab_badge']}
+            id='mastodon-settings--notifications-tab_badge'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.tab_badge' defaultMessage="Unread notifications badge" />
+            <span className='hint'><FormattedMessage id='settings.notifications.tab_badge.hint' defaultMessage="Display a badge for unread notifications in the column icons when the notifications column isn't open" /></span>
+          </LocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['notifications', 'favicon_badge']}
+            id='mastodon-settings--notifications-favicon_badge'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.notifications.favicon_badge' defaultMessage='Unread notifications favicon badge' />
+            <span className='hint'><FormattedMessage id='settings.notifications.favicon_badge.hint' defaultMessage="Add a badge for unread notifications to the favicon" /></span>
+          </LocalSettingsPageItem>
+        </section>
+        <section>
+          <h2><FormattedMessage id='settings.layout_opts' defaultMessage='Layout options' /></h2>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['layout']}
+            id='mastodon-settings--layout'
+            options={[
+              { value: 'auto', message: intl.formatMessage(messages.layout_auto), hint: intl.formatMessage(messages.layout_auto_hint) },
+              { value: 'multiple', message: intl.formatMessage(messages.layout_desktop), hint: intl.formatMessage(messages.layout_desktop_hint) },
+              { value: 'single', message: intl.formatMessage(messages.layout_mobile), hint: intl.formatMessage(messages.layout_mobile_hint) },
+            ]}
+            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)' />
+            <span className='hint'><FormattedMessage id='settings.wide_view_hint' defaultMessage='Stretches columns to better fill the available space.' /></span>
+          </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>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['swipe_to_change_columns']}
+            id='mastodon-settings--swipe_to_change_columns'
+            onChange={onChange}
+          >
+            <FormattedMessage id='settings.swipe_to_change_columns' defaultMessage='Allow swiping to change columns (Mobile only)' />
+          </LocalSettingsPageItem>
+        </section>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page compose_box_opts'>
+        <h1><FormattedMessage id='settings.compose_box_opts' defaultMessage='Compose box' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['always_show_spoilers_field']}
+          id='mastodon-settings--always_show_spoilers_field'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.always_show_spoilers_field' defaultMessage='Always enable the Content Warning field' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['prepend_cw_re']}
+          id='mastodon-settings--prepend_cw_re'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.prepend_cw_re' defaultMessage='Prepend “re: ” to content warnings when replying' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['preselect_on_reply']}
+          id='mastodon-settings--preselect_on_reply'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.preselect_on_reply' defaultMessage='Pre-select usernames on reply' />
+          <span className='hint'><FormattedMessage id='settings.preselect_on_reply_hint' defaultMessage='When replying to a conversation with multiple participants, pre-select usernames past the first' /></span>
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_missing_media_description']}
+          id='mastodon-settings--confirm_missing_media_description'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_missing_media_description' defaultMessage='Show confirmation dialog before sending toots lacking media descriptions' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['confirm_before_clearing_draft']}
+          id='mastodon-settings--confirm_before_clearing_draft'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.confirm_before_clearing_draft' defaultMessage='Show confirmation dialog before overwriting the message being composed' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['show_content_type_choice']}
+          id='mastodon-settings--show_content_type_choice'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.show_content_type_choice' defaultMessage='Show content-type choice when authoring toots' />
+        </LocalSettingsPageItem>
+        <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>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['side_arm_reply_mode']}
+          id='mastodon-settings--side_arm_reply_mode'
+          options={[
+            { value: 'keep', message: intl.formatMessage(messages.side_arm_keep) },
+            { value: 'copy', message: intl.formatMessage(messages.side_arm_copy) },
+            { value: 'restrict', message: intl.formatMessage(messages.side_arm_restrict) },
+          ]}
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.side_arm_reply_mode' defaultMessage='When replying to a toot:' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page content_warnings'>
+        <h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['content_warnings', 'auto_unfold']}
+          id='mastodon-settings--content_warnings-auto_unfold'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['content_warnings', 'filter']}
+          id='mastodon-settings--content_warnings-auto_unfold'
+          onChange={onChange}
+          dependsOn={[['content_warnings', 'auto_unfold']]}
+          placeholder={intl.formatMessage(messages.regexp)}
+        >
+          <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page filters'>
+        <h1><FormattedMessage id='settings.filters' defaultMessage='Filters' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['filtering_behavior']}
+          id='mastodon-settings--filters-behavior'
+          onChange={onChange}
+          options={[
+            { value: 'drop', message: intl.formatMessage(messages.filters_drop) },
+            { value: 'upstream', message: intl.formatMessage(messages.filters_upstream) },
+            { value: 'hide', message: intl.formatMessage(messages.filters_hide) },
+            { value: 'content_warning', message: intl.formatMessage(messages.filters_cw) }
+          ]}
+        >
+          <FormattedMessage id='settings.filtering_behavior' defaultMessage='Filtering behavior' />
+        </LocalSettingsPageItem>
+      </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>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['collapsed', 'show_action_bar']}
+          id='mastodon-settings--collapsed-show-action-bar'
+          onChange={onChange}
+          dependsOn={[['collapsed', 'enabled']]}
+        >
+          <FormattedMessage id='settings.show_action_bar' defaultMessage='Show action buttons in 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' />
+          <span className='hint'><FormattedMessage id='settings.media_letterbox_hint' defaultMessage='Scale down and letterbox media to fill the image containers instead of stretching and cropping them' /></span>
+        </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>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['inline_preview_cards']}
+          id='mastodon-settings--inline-preview-cards'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' />
+        </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'reveal_behind_cw']}
+          id='mastodon-settings--reveal-behind-cw'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' />
+        </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/flavours/glitch/features/local_settings/page/item/index.js b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
new file mode 100644
index 000000000..5a68523f6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
@@ -0,0 +1,112 @@
+//  Package imports
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+
+//  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
+
+export default class LocalSettingsPageItem extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node.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,
+      hint: PropTypes.string,
+    })),
+    settings: ImmutablePropTypes.map.isRequired,
+    placeholder: PropTypes.string,
+  };
+
+  handleChange = e => {
+    const { target } = e;
+    const { item, onChange, options, placeholder } = this.props;
+    if (options && options.length > 0) onChange(item, target.value);
+    else if (placeholder) onChange(item, target.value);
+    else onChange(item, target.checked);
+  }
+
+  render () {
+    const { handleChange } = this;
+    const { settings, item, id, options, children, dependsOn, dependsOnNot, placeholder } = 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) => {
+        let optionId = `${id}--${opt.value}`;
+        return (
+          <label htmlFor={optionId}>
+            <input type='radio'
+              name={id}
+              id={optionId}
+              value={opt.value}
+              onBlur={handleChange}
+              onChange={handleChange}
+              checked={ currentValue === opt.value }
+              disabled={!enabled}
+            />
+            {opt.message}
+            {opt.hint && <span className='hint'>{opt.hint}</span>}
+          </label>
+        );
+      });
+      return (
+        <div className='glitch local-settings__page__item radio_buttons'>
+          <fieldset>
+            <legend>{children}</legend>
+            {optionElems}
+          </fieldset>
+        </div>
+      );
+    } else if (placeholder) {
+      return (
+        <div className='glitch local-settings__page__item string'>
+          <label htmlFor={id}>
+            <p>{children}</p>
+            <p>
+              <input
+                id={id}
+                type='text'
+                value={settings.getIn(item)}
+                placeholder={placeholder}
+                onChange={handleChange}
+                disabled={!enabled}
+              />
+            </p>
+          </label>
+        </div>
+      );
+    } else return (
+      <div className='glitch local-settings__page__item boolean'>
+        <label htmlFor={id}>
+          <input
+            id={id}
+            type='checkbox'
+            checked={settings.getIn(item)}
+            onChange={handleChange}
+            disabled={!enabled}
+          />
+          {children}
+        </label>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/mutes/index.js b/app/javascript/flavours/glitch/features/mutes/index.js
new file mode 100644
index 000000000..9f0d5a43e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/mutes/index.js
@@ -0,0 +1,79 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import { fetchMutes, expandMutes } from 'flavours/glitch/actions/mutes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  heading: { id: 'column.mutes', defaultMessage: 'Muted users' },
+});
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['user_lists', 'mutes', 'items']),
+  hasMore: !!state.getIn(['user_lists', 'mutes', 'next']),
+  isLoading: state.getIn(['user_lists', 'mutes', 'isLoading'], true),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Mutes extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    hasMore: PropTypes.bool,
+    isLoading: PropTypes.bool,
+    accountIds: ImmutablePropTypes.list,
+    intl: PropTypes.object.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchMutes());
+  }
+
+  handleLoadMore = debounce(() => {
+    this.props.dispatch(expandMutes());
+  }, 300, { leading: true });
+
+  render () {
+    const { intl, accountIds, hasMore, multiColumn, isLoading } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='empty_column.mutes' defaultMessage="You haven't muted any users yet." />;
+
+    return (
+      <Column bindToDocument={!multiColumn} name='mutes' icon='volume-off' heading={intl.formatMessage(messages.heading)}>
+        <ColumnBackButtonSlim />
+        <ScrollableList
+          scrollKey='mutes'
+          onLoadMore={this.handleLoadMore}
+          hasMore={hasMore}
+          isLoading={isLoading}
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js
new file mode 100644
index 000000000..ee77cfb8e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/clear_column_button.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+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}><Icon id='eraser' /> <FormattedMessage id='notifications.clear' defaultMessage='Clear notifications' /></button>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
new file mode 100644
index 000000000..e4d5d0eda
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -0,0 +1,119 @@
+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,
+    onClear: PropTypes.func.isRequired,
+  };
+
+  onPushChange = (path, checked) => {
+    this.props.onChange(['push', ...path], checked);
+  }
+
+  render () {
+    const { settings, pushSettings, onChange, onClear } = this.props;
+
+    const filterShowStr = <FormattedMessage id='notifications.column_settings.filter_bar.show' defaultMessage='Show' />;
+    const filterAdvancedStr = <FormattedMessage id='notifications.column_settings.filter_bar.advanced' defaultMessage='Display all categories' />;
+    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-filter-bar'>
+          <span id='notifications-filter-bar' className='column-settings__section'>
+            <FormattedMessage id='notifications.column_settings.filter_bar.category' defaultMessage='Quick filter bar' />
+          </span>
+          <div className='column-settings__row'>
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'show']} onChange={onChange} label={filterShowStr} />
+            <SettingToggle id='show-filter-bar' prefix='notifications' settings={settings} settingPath={['quickFilter', 'advanced']} onChange={onChange} label={filterAdvancedStr} />
+          </div>
+        </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} settingPath={['alerts', 'follow']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-follow-request'>
+          <span id='notifications-follow-request' className='column-settings__section'><FormattedMessage id='notifications.column_settings.follow_request' defaultMessage='New follow requests:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'follow_request']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'follow_request']} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'follow_request']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'follow_request']} 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} settingPath={['alerts', 'favourite']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'favourite']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'favourite']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['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} settingPath={['alerts', 'mention']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'mention']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'mention']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['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} settingPath={['alerts', 'reblog']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'reblog']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'reblog']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+
+        <div role='group' aria-labelledby='notifications-poll'>
+          <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
new file mode 100644
index 000000000..6118305d6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
@@ -0,0 +1,102 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+
+const tooltips = defineMessages({
+  mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
+  favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
+  boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+  polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
+  follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+});
+
+export default @injectIntl
+class FilterBar extends React.PureComponent {
+
+  static propTypes = {
+    selectFilter: PropTypes.func.isRequired,
+    selectedFilter: PropTypes.string.isRequired,
+    advancedMode: PropTypes.bool.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  onClick (notificationType) {
+    return () => this.props.selectFilter(notificationType);
+  }
+
+  render () {
+    const { selectedFilter, advancedMode, intl } = this.props;
+    const renderedElement = !advancedMode ? (
+      <div className='notification__filter-bar'>
+        <button
+          className={selectedFilter === 'all' ? 'active' : ''}
+          onClick={this.onClick('all')}
+        >
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </button>
+        <button
+          className={selectedFilter === 'mention' ? 'active' : ''}
+          onClick={this.onClick('mention')}
+        >
+          <FormattedMessage
+            id='notifications.filter.mentions'
+            defaultMessage='Mentions'
+          />
+        </button>
+      </div>
+    ) : (
+      <div className='notification__filter-bar'>
+        <button
+          className={selectedFilter === 'all' ? 'active' : ''}
+          onClick={this.onClick('all')}
+        >
+          <FormattedMessage
+            id='notifications.filter.all'
+            defaultMessage='All'
+          />
+        </button>
+        <button
+          className={selectedFilter === 'mention' ? 'active' : ''}
+          onClick={this.onClick('mention')}
+          title={intl.formatMessage(tooltips.mentions)}
+        >
+          <Icon id='reply-all' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'favourite' ? 'active' : ''}
+          onClick={this.onClick('favourite')}
+          title={intl.formatMessage(tooltips.favourites)}
+        >
+          <Icon id='star' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'reblog' ? 'active' : ''}
+          onClick={this.onClick('reblog')}
+          title={intl.formatMessage(tooltips.boosts)}
+        >
+          <Icon id='retweet' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'poll' ? 'active' : ''}
+          onClick={this.onClick('poll')}
+          title={intl.formatMessage(tooltips.polls)}
+        >
+          <Icon id='tasks' fixedWidth />
+        </button>
+        <button
+          className={selectedFilter === 'follow' ? 'active' : ''}
+          onClick={this.onClick('follow')}
+          title={intl.formatMessage(tooltips.follows)}
+        >
+          <Icon id='user-plus' fixedWidth />
+        </button>
+      </div>
+    );
+    return renderedElement;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow.js b/app/javascript/flavours/glitch/features/notifications/components/follow.js
new file mode 100644
index 000000000..2b71f3107
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow.js
@@ -0,0 +1,99 @@
+//  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 'flavours/glitch/components/permalink';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class NotificationFollow extends ImmutablePureComponent {
+
+  static propTypes = {
+    hidden: PropTypes.bool,
+    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, hidden } = this.props;
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/accounts/${account.get('id')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    //  Renders.
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon fixedWidth id='user-plus' />
+            </div>
+
+            <FormattedMessage
+              id='notification.follow'
+              defaultMessage='{name} followed you'
+              values={{ name: link }}
+            />
+          </div>
+
+          <AccountContainer hidden={hidden} id={account.get('id')} withNote={false} />
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/follow_request.js b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
new file mode 100644
index 000000000..d73dac434
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/follow_request.js
@@ -0,0 +1,130 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Permalink from 'flavours/glitch/components/permalink';
+import IconButton from 'flavours/glitch/components/icon_button';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import { HotKeys } from 'react-hotkeys';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
+  reject: { id: 'follow_request.reject', defaultMessage: 'Reject' },
+});
+
+export default @injectIntl
+class FollowRequest extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    onAuthorize: PropTypes.func.isRequired,
+    onReject: PropTypes.func.isRequired,
+    intl: PropTypes.object.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 { intl, hidden, account, onAuthorize, onReject, notification } = this.props;
+
+    if (!account) {
+      return <div />;
+    }
+
+    if (hidden) {
+      return (
+        <Fragment>
+          {account.get('display_name')}
+          {account.get('username')}
+        </Fragment>
+      );
+    }
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/accounts/${account.get('id')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-follow-request focusable' tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='user' fixedWidth />
+            </div>
+
+            <FormattedMessage
+              id='notification.follow_request'
+              defaultMessage='{name} has requested to follow you'
+              values={{ name: link }}
+            />
+          </div>
+
+          <div className='account'>
+            <div className='account__wrapper'>
+              <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
+                <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
+                <DisplayName account={account} />
+              </Permalink>
+
+              <div className='account__relationship'>
+                <IconButton title={intl.formatMessage(messages.authorize)} icon='check' onClick={onAuthorize} />
+                <IconButton title={intl.formatMessage(messages.reject)} icon='times' onClick={onReject} />
+              </div>
+            </div>
+          </div>
+
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
new file mode 100644
index 000000000..62fc28386
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -0,0 +1,150 @@
+//  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 'flavours/glitch/containers/status_container';
+import NotificationFollow from './follow';
+import NotificationFollowRequestContainer from '../containers/follow_request_container';
+
+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,
+    getScrollPosition: PropTypes.func,
+    updateScrollBottom: PropTypes.func,
+    cacheMediaWidth: PropTypes.func,
+    cachedMediaWidth: PropTypes.number,
+    onUnmount: PropTypes.func,
+  };
+
+  render () {
+    const {
+      hidden,
+      notification,
+      onMoveDown,
+      onMoveUp,
+      onMention,
+      getScrollPosition,
+      updateScrollBottom,
+    } = this.props;
+
+    switch(notification.get('type')) {
+    case 'follow':
+      return (
+        <NotificationFollow
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+        />
+      );
+    case 'follow_request':
+      return (
+        <NotificationFollowRequestContainer
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+        />
+      );
+    case 'mention':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          contextType='notifications'
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+        />
+      );
+    case 'favourite':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='favourite'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+        />
+      );
+    case 'reblog':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='reblog'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+        />
+      );
+    case 'poll':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='poll'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+        />
+      );
+    default:
+      return null;
+    }
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/overlay.js b/app/javascript/flavours/glitch/features/notifications/components/overlay.js
new file mode 100644
index 000000000..f3ccafc06
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/overlay.js
@@ -0,0 +1,58 @@
+/**
+ * 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';
+import Icon from 'flavours/glitch/components/icon';
+
+const messages = defineMessages({
+  markForDeletion: { id: 'notification.markForDeletion', defaultMessage: 'Mark for deletion' },
+});
+
+export default @injectIntl
+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 ? (<Icon id='check' />) : ''}
+          </div>
+        </div>
+      </div>
+    ) : null;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
new file mode 100644
index 000000000..0264b6815
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/setting_toggle.js
@@ -0,0 +1,35 @@
+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,
+    settingPath: PropTypes.array.isRequired,
+    label: PropTypes.node.isRequired,
+    meta: PropTypes.node,
+    onChange: PropTypes.func.isRequired,
+    defaultValue: PropTypes.bool,
+  }
+
+  onChange = ({ target }) => {
+    this.props.onChange(this.props.settingPath, target.checked);
+  }
+
+  render () {
+    const { prefix, settings, settingPath, label, meta, defaultValue } = this.props;
+    const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-');
+
+    return (
+      <div className='setting-toggle'>
+        <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} 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/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
new file mode 100644
index 000000000..4b863712a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -0,0 +1,43 @@
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl } from 'react-intl';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { setFilter } from 'flavours/glitch/actions/notifications';
+import { clearNotifications } from 'flavours/glitch/actions/notifications';
+import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
+import { openModal } from 'flavours/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 (path, checked) {
+    if (path[0] === 'push') {
+      dispatch(changePushNotifications(path.slice(1), checked));
+    } else if (path[0] === 'quickFilter') {
+      dispatch(changeSetting(['notifications', ...path], checked));
+      dispatch(setFilter('all'));
+    } else {
+      dispatch(changeSetting(['notifications', ...path], checked));
+    }
+  },
+
+  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/flavours/glitch/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
new file mode 100644
index 000000000..4d495c290
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import FilterBar from '../components/filter_bar';
+import { setFilter } from '../../../actions/notifications';
+
+const makeMapStateToProps = state => ({
+  selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+  advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+  selectFilter (newActiveFilter) {
+    dispatch(setFilter(newActiveFilter));
+  },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js
new file mode 100644
index 000000000..82357adfb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/follow_request_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import FollowRequest from '../components/follow_request';
+import { authorizeFollowRequest, rejectFollowRequest } from 'flavours/glitch/actions/accounts';
+
+const mapDispatchToProps = (dispatch, { account }) => ({
+  onAuthorize () {
+    dispatch(authorizeFollowRequest(account.get('id')));
+  },
+
+  onReject () {
+    dispatch(rejectFollowRequest(account.get('id')));
+  },
+});
+
+export default connect(null, mapDispatchToProps)(FollowRequest);
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
new file mode 100644
index 000000000..be007f30b
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/notification_container.js
@@ -0,0 +1,26 @@
+//  Package imports.
+import { connect } from 'react-redux';
+
+//  Our imports.
+import { makeGetNotification } from 'flavours/glitch/selectors';
+import Notification from '../components/notification';
+import { mentionCompose } from 'flavours/glitch/actions/compose';
+
+const makeMapStateToProps = () => {
+  const getNotification = makeGetNotification();
+
+  const mapStateToProps = (state, props) => ({
+    notification: getNotification(state, props.notification, props.accountId),
+    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/flavours/glitch/features/notifications/containers/overlay_container.js b/app/javascript/flavours/glitch/features/notifications/containers/overlay_container.js
new file mode 100644
index 000000000..ee2d19814
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/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/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
new file mode 100644
index 000000000..26710feff
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -0,0 +1,314 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import {
+  enterNotificationClearingMode,
+  expandNotifications,
+  scrollTopNotifications,
+  mountNotifications,
+  unmountNotifications,
+  loadPending,
+} from 'flavours/glitch/actions/notifications';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import NotificationContainer from './containers/notification_container';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import FilterBarContainer from './containers/filter_bar_container';
+import { createSelector } from 'reselect';
+import { List as ImmutableList } from 'immutable';
+import { debounce } from 'lodash';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+import LoadGap from 'flavours/glitch/components/load_gap';
+import Icon from 'flavours/glitch/components/icon';
+
+import NotificationPurgeButtonsContainer from 'flavours/glitch/containers/notification_purge_buttons_container';
+
+const messages = defineMessages({
+  title: { id: 'column.notifications', defaultMessage: 'Notifications' },
+  enterNotifCleaning : { id: 'notification_purge.start', defaultMessage: 'Enter notification cleaning mode' },
+});
+
+const getNotifications = createSelector([
+  state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+  state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+  state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  state => state.getIn(['notifications', 'items']),
+], (showFilterBar, allowedType, excludedTypes, notifications) => {
+  if (!showFilterBar || allowedType === 'all') {
+    // used if user changed the notification settings after loading the notifications from the server
+    // otherwise a list of notifications will come pre-filtered from the backend
+    // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
+    return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
+  }
+  return notifications.filter(item => item !== null && allowedType === item.get('type'));
+});
+
+const mapStateToProps = state => ({
+  showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+  notifications: getNotifications(state),
+  localSettings:  state.get('local_settings'),
+  isLoading: state.getIn(['notifications', 'isLoading'], true),
+  isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
+  hasMore: state.getIn(['notifications', 'hasMore']),
+  numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
+  notifCleaningActive: state.getIn(['notifications', 'cleaningMode']),
+});
+
+/* glitch */
+const mapDispatchToProps = dispatch => ({
+  onEnterCleaningMode(yes) {
+    dispatch(enterNotificationClearingMode(yes));
+  },
+  onMount() {
+    dispatch(mountNotifications());
+  },
+  onUnmount() {
+    dispatch(unmountNotifications());
+  },
+  dispatch,
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class Notifications extends React.PureComponent {
+
+  static propTypes = {
+    columnId: PropTypes.string,
+    notifications: ImmutablePropTypes.list.isRequired,
+    showFilterBar: PropTypes.bool.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    shouldUpdateScroll: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    isLoading: PropTypes.bool,
+    isUnread: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    hasMore: PropTypes.bool,
+    numPending: PropTypes.number,
+    localSettings: ImmutablePropTypes.map,
+    notifCleaningActive: PropTypes.bool,
+    onEnterCleaningMode: PropTypes.func,
+    onMount: PropTypes.func,
+    onUnmount: PropTypes.func,
+  };
+
+  static defaultProps = {
+    trackScroll: true,
+  };
+
+  state = {
+    animatingNCD: false,
+  };
+
+  handleLoadGap = (maxId) => {
+    this.props.dispatch(expandNotifications({ maxId }));
+  };
+
+  handleLoadOlder = debounce(() => {
+    const last = this.props.notifications.last();
+    this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
+  }, 300, { leading: true });
+
+  handleLoadPending = () => {
+    this.props.dispatch(loadPending());
+  };
+
+  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 !== null && item.get('id') === id) - 1;
+    this._selectChild(elementIndex, true);
+  }
+
+  handleMoveDown = id => {
+    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
+    this._selectChild(elementIndex, false);
+  }
+
+  _selectChild (index, align_top) {
+    const container = this.column.node;
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  componentDidMount () {
+    const { onMount } = this.props;
+    if (onMount) {
+      onMount();
+    }
+  }
+
+  componentWillUnmount () {
+    const { onUnmount } = this.props;
+    if (onUnmount) {
+      onUnmount();
+    }
+  }
+
+  handleTransitionEndNCD = () => {
+    this.setState({ animatingNCD: false });
+  }
+
+  onEnterCleaningMode = () => {
+    this.setState({ animatingNCD: true });
+    this.props.onEnterCleaningMode(!this.props.notifCleaningActive);
+  }
+
+  render () {
+    const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props;
+    const { notifCleaning, notifCleaningActive } = this.props;
+    const { animatingNCD } = this.state;
+    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;
+
+    const filterBarContainer = showFilterBar
+      ? (<FilterBarContainer />)
+      : null;
+
+    if (isLoading && this.scrollableContent) {
+      scrollableContent = this.scrollableContent;
+    } else if (notifications.size > 0 || hasMore) {
+      scrollableContent = notifications.map((item, index) => item === null ? (
+        <LoadGap
+          key={'gap:' + notifications.getIn([index + 1, 'id'])}
+          disabled={isLoading}
+          maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
+          onClick={this.handleLoadGap}
+        />
+      ) : (
+        <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}
+        showLoading={isLoading && notifications.size === 0}
+        hasMore={hasMore}
+        numPending={numPending}
+        emptyMessage={emptyMessage}
+        onLoadMore={this.handleLoadOlder}
+        onLoadPending={this.handleLoadPending}
+        onScrollToTop={this.handleScrollToTop}
+        onScroll={this.handleScroll}
+        shouldUpdateScroll={shouldUpdateScroll}
+        bindToDocument={!multiColumn}
+      >
+        {scrollableContent}
+      </ScrollableList>
+    );
+
+    const notifCleaningButtonClassName = classNames('column-header__button', {
+      'active': notifCleaningActive,
+    });
+
+    const notifCleaningDrawerClassName = classNames('ncd column-header__collapsible', {
+      'collapsed': !notifCleaningActive,
+      'animating': animatingNCD,
+    });
+
+    const msgEnterNotifCleaning = intl.formatMessage(messages.enterNotifCleaning);
+
+    const notifCleaningButton = (
+      <button
+        aria-label={msgEnterNotifCleaning}
+        title={msgEnterNotifCleaning}
+        onClick={this.onEnterCleaningMode}
+        className={notifCleaningButtonClassName}
+      >
+        <Icon id='eraser' />
+      </button>
+    );
+
+    const notifCleaningDrawer = (
+      <div className={notifCleaningDrawerClassName} onTransitionEnd={this.handleTransitionEndNCD}>
+        <div className='column-header__collapsible-inner nopad-drawer'>
+          {(notifCleaningActive || animatingNCD) ? (<NotificationPurgeButtonsContainer />) : null }
+        </div>
+      </div>
+    );
+
+    return (
+      <Column
+        bindToDocument={!multiColumn}
+        ref={this.setColumnRef}
+        name='notifications'
+        extraClasses={this.props.notifCleaningActive ? 'notif-cleaning' : null}
+        label={intl.formatMessage(messages.title)}
+      >
+        <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}
+          extraButton={notifCleaningButton}
+          appendContent={notifCleaningDrawer}
+        >
+          <ColumnSettingsContainer />
+        </ColumnHeader>
+        {filterBarContainer}
+        {scrollContainer}
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js
new file mode 100644
index 000000000..149d05c32
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/account_container.js
@@ -0,0 +1,24 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { injectIntl } from 'react-intl';
+import { pinAccount, unpinAccount } from 'flavours/glitch/actions/accounts';
+import Account from 'flavours/glitch/features/list_editor/components/account';
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = (state, { accountId, added }) => ({
+    account: getAccount(state, accountId),
+    added: typeof added === 'undefined' ? state.getIn(['pinnedAccountsEditor', 'accounts', 'items']).includes(accountId) : added,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { accountId }) => ({
+  onRemove: () => dispatch(unpinAccount(accountId)),
+  onAdd: () => dispatch(pinAccount(accountId)),
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Account));
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js
new file mode 100644
index 000000000..5a1efce0a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/containers/search_container.js
@@ -0,0 +1,21 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { injectIntl } from 'react-intl';
+import {
+  fetchPinnedAccountsSuggestions,
+  clearPinnedAccountsSuggestions,
+  changePinnedAccountsSuggestions
+} from '../../../actions/accounts';
+import Search from 'flavours/glitch/features/list_editor/components/search';
+
+const mapStateToProps = state => ({
+  value: state.getIn(['pinnedAccountsEditor', 'suggestions', 'value']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onSubmit: value => dispatch(fetchPinnedAccountsSuggestions(value)),
+  onClear: () => dispatch(clearPinnedAccountsSuggestions()),
+  onChange: value => dispatch(changePinnedAccountsSuggestions(value)),
+});
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Search));
diff --git a/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js
new file mode 100644
index 000000000..5f03c7e93
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_accounts_editor/index.js
@@ -0,0 +1,78 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { connect } from 'react-redux';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import { fetchPinnedAccounts, clearPinnedAccountsSuggestions, resetPinnedAccountsEditor } from 'flavours/glitch/actions/accounts';
+import AccountContainer from './containers/account_container';
+import SearchContainer from './containers/search_container';
+import Motion from 'flavours/glitch/util/optional_motion';
+import spring from 'react-motion/lib/spring';
+
+const mapStateToProps = state => ({
+  accountIds: state.getIn(['pinnedAccountsEditor', 'accounts', 'items']),
+  searchAccountIds: state.getIn(['pinnedAccountsEditor', 'suggestions', 'items']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  onInitialize: () => dispatch(fetchPinnedAccounts()),
+  onClear: () => dispatch(clearPinnedAccountsSuggestions()),
+  onReset: () => dispatch(resetPinnedAccountsEditor()),
+});
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class PinnedAccountsEditor extends ImmutablePureComponent {
+
+  static propTypes = {
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    onInitialize: PropTypes.func.isRequired,
+    onClear: PropTypes.func.isRequired,
+    onReset: PropTypes.func.isRequired,
+    title: PropTypes.string.isRequired,
+    accountIds: ImmutablePropTypes.list.isRequired,
+    searchAccountIds: ImmutablePropTypes.list.isRequired,
+  };
+
+  componentDidMount () {
+    const { onInitialize } = this.props;
+    onInitialize();
+  }
+
+  componentWillUnmount () {
+    const { onReset } = this.props;
+    onReset();
+  }
+
+  render () {
+    const { accountIds, searchAccountIds, onClear } = this.props;
+    const showSearch = searchAccountIds.size > 0;
+
+    return (
+      <div className='modal-root__modal list-editor'>
+        <h4><FormattedMessage id='endorsed_accounts_editor.endorsed_accounts' defaultMessage='Featured accounts' /></h4>
+
+        <SearchContainer />
+
+        <div className='drawer__pager'>
+          <div className='drawer__inner list-editor__accounts'>
+            {accountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} added />)}
+          </div>
+
+          {showSearch && <div role='button' tabIndex='-1' className='drawer__backdrop' onClick={onClear} />}
+
+          <Motion defaultStyle={{ x: -100 }} style={{ x: spring(showSearch ? 0 : -100, { stiffness: 210, damping: 20 }) }}>
+            {({ x }) =>
+              (<div className='drawer__inner backdrop' style={{ transform: x === 0 ? null : `translateX(${x}%)`, visibility: x === -100 ? 'hidden' : 'visible' }}>
+                {searchAccountIds.map(accountId => <AccountContainer key={accountId} accountId={accountId} />)}
+              </div>)
+            }
+          </Motion>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/pinned_statuses/index.js b/app/javascript/flavours/glitch/features/pinned_statuses/index.js
new file mode 100644
index 000000000..34d8e465f
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/pinned_statuses/index.js
@@ -0,0 +1,61 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { fetchPinnedStatuses } from 'flavours/glitch/actions/pin_statuses';
+import Column from 'flavours/glitch/features/ui/components/column';
+import ColumnBackButtonSlim from 'flavours/glitch/components/column_back_button_slim';
+import StatusList from 'flavours/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']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class PinnedStatuses extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    intl: PropTypes.object.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    multiColumn: PropTypes.bool,
+  };
+
+  componentWillMount () {
+    this.props.dispatch(fetchPinnedStatuses());
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  render () {
+    const { intl, statusIds, hasMore, multiColumn } = this.props;
+
+    return (
+      <Column bindToDocument={!multiColumn} icon='thumb-tack' heading={intl.formatMessage(messages.heading)} ref={this.setRef}>
+        <ColumnBackButtonSlim />
+        <StatusList
+          statusIds={statusIds}
+          scrollKey='pinned_statuses'
+          hasMore={hasMore}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js
new file mode 100644
index 000000000..756b6fe06
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/components/column_settings.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import SettingToggle from '../../notifications/components/setting_toggle';
+
+export default @injectIntl
+class ColumnSettings extends React.PureComponent {
+
+  static propTypes = {
+    settings: ImmutablePropTypes.map.isRequired,
+    onChange: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+  };
+
+  render () {
+    const { settings, onChange } = this.props;
+
+    return (
+      <div>
+        <div className='column-settings__row'>
+          <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
+          <SettingToggle settings={settings} settingPath={['other', 'onlyRemote']} onChange={onChange} label={<FormattedMessage id='community.column_settings.remote_only' defaultMessage='Remote only' />} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js
new file mode 100644
index 000000000..5091bfb90
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/containers/column_settings_container.js
@@ -0,0 +1,28 @@
+import { connect } from 'react-redux';
+import ColumnSettings from '../components/column_settings';
+import { changeSetting } from 'flavours/glitch/actions/settings';
+import { changeColumnParams } from 'flavours/glitch/actions/columns';
+ 
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+
+  return {
+    settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'public']),
+  };
+};
+
+const mapDispatchToProps = (dispatch, { columnId }) => {
+  return {
+    onChange (key, checked) {
+      if (columnId) {
+        dispatch(changeColumnParams(columnId, key, checked));
+      } else {
+        dispatch(changeSetting(['public', ...key], checked));
+      }
+    },
+  };
+};
+
+export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);
diff --git a/app/javascript/flavours/glitch/features/public_timeline/index.js b/app/javascript/flavours/glitch/features/public_timeline/index.js
new file mode 100644
index 000000000..3f720b885
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/public_timeline/index.js
@@ -0,0 +1,138 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import PropTypes from 'prop-types';
+import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
+import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/columns';
+import ColumnSettingsContainer from './containers/column_settings_container';
+import { connectPublicStream } from 'flavours/glitch/actions/streaming';
+
+const messages = defineMessages({
+  title: { id: 'column.public', defaultMessage: 'Federated timeline' },
+});
+
+const mapStateToProps = (state, { columnId }) => {
+  const uuid = columnId;
+  const columns = state.getIn(['settings', 'columns']);
+  const index = columns.findIndex(c => c.get('uuid') === uuid);
+  const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'public', 'other', 'onlyMedia']);
+  const onlyRemote = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyRemote']) : state.getIn(['settings', 'public', 'other', 'onlyRemote']);
+  const timelineState = state.getIn(['timelines', `public${onlyMedia ? ':media' : ''}`]);
+
+  return {
+    hasUnread: !!timelineState && timelineState.get('unread') > 0,
+    onlyMedia,
+    onlyRemote,
+  };
+};
+
+export default @connect(mapStateToProps)
+@injectIntl
+class PublicTimeline extends React.PureComponent {
+
+  static defaultProps = {
+    onlyMedia: false,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+    columnId: PropTypes.string,
+    multiColumn: PropTypes.bool,
+    hasUnread: PropTypes.bool,
+    onlyMedia: PropTypes.bool,
+    onlyRemote: PropTypes.bool,
+  };
+
+  handlePin = () => {
+    const { columnId, dispatch, onlyMedia, onlyRemote } = this.props;
+
+    if (columnId) {
+      dispatch(removeColumn(columnId));
+    } else {
+      dispatch(addColumn(onlyRemote ? 'REMOTE' : 'PUBLIC', { other: { onlyMedia, onlyRemote } }));
+    }
+  }
+
+  handleMove = (dir) => {
+    const { columnId, dispatch } = this.props;
+    dispatch(moveColumn(columnId, dir));
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  componentDidMount () {
+    const { dispatch, onlyMedia, onlyRemote } = this.props;
+
+    dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
+    this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.onlyRemote !== this.props.onlyRemote) {
+      const { dispatch, onlyMedia, onlyRemote } = this.props;
+
+      this.disconnect();
+      dispatch(expandPublicTimeline({ onlyMedia, onlyRemote }));
+      this.disconnect = dispatch(connectPublicStream({ onlyMedia, onlyRemote }));
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.disconnect) {
+      this.disconnect();
+      this.disconnect = null;
+    }
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleLoadMore = maxId => {
+    const { dispatch, onlyMedia, onlyRemote } = this.props;
+
+    dispatch(expandPublicTimeline({ maxId, onlyMedia, onlyRemote }));
+  }
+
+  render () {
+    const { intl, columnId, hasUnread, multiColumn, onlyMedia, onlyRemote } = this.props;
+    const pinned = !!columnId;
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setRef} name='federated' label={intl.formatMessage(messages.title)}>
+        <ColumnHeader
+          icon='globe'
+          active={hasUnread}
+          title={intl.formatMessage(messages.title)}
+          onPin={this.handlePin}
+          onMove={this.handleMove}
+          onClick={this.handleHeaderClick}
+          pinned={pinned}
+          multiColumn={multiColumn}
+        >
+          <ColumnSettingsContainer columnId={columnId} />
+        </ColumnHeader>
+
+        <StatusListContainer
+          timelineId={`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`}
+          onLoadMore={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 servers to fill it up' />}
+          bindToDocument={!multiColumn}
+        />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/reblogs/index.js b/app/javascript/flavours/glitch/features/reblogs/index.js
new file mode 100644
index 000000000..d88916d19
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/reblogs/index.js
@@ -0,0 +1,99 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+import { fetchReblogs } from 'flavours/glitch/actions/interactions';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Column from 'flavours/glitch/features/ui/components/column';
+import Icon from 'flavours/glitch/components/icon';
+import ColumnHeader from 'flavours/glitch/components/column_header';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ScrollableList from 'flavours/glitch/components/scrollable_list';
+
+const messages = defineMessages({
+  heading: { id: 'column.reblogged_by', defaultMessage: 'Boosted by' },
+  refresh: { id: 'refresh', defaultMessage: 'Refresh' },
+});
+
+const mapStateToProps = (state, props) => ({
+  accountIds: state.getIn(['user_lists', 'reblogged_by', props.params.statusId]),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+class Reblogs extends ImmutablePureComponent {
+
+  static propTypes = {
+    params: PropTypes.object.isRequired,
+    dispatch: PropTypes.func.isRequired,
+    accountIds: ImmutablePropTypes.list,
+    multiColumn: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentWillMount () {
+    if (!this.props.accountIds) {
+      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));
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  setRef = c => {
+    this.column = c;
+  }
+
+  handleRefresh = () => {
+    this.props.dispatch(fetchReblogs(this.props.params.statusId));
+  }
+
+  render () {
+    const { intl, accountIds, multiColumn } = this.props;
+
+    if (!accountIds) {
+      return (
+        <Column>
+          <LoadingIndicator />
+        </Column>
+      );
+    }
+
+    const emptyMessage = <FormattedMessage id='status.reblogs.empty' defaultMessage='No one has boosted this toot yet. When someone does, they will show up here.' />;
+
+    return (
+      <Column ref={this.setRef}>
+        <ColumnHeader
+          icon='retweet'
+          title={intl.formatMessage(messages.heading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(messages.refresh)} aria-label={intl.formatMessage(messages.refresh)} onClick={this.handleRefresh}><Icon id='refresh' /></button>
+          )}
+        />
+
+        <ScrollableList
+          scrollKey='reblogs'
+          emptyMessage={emptyMessage}
+          bindToDocument={!multiColumn}
+        >
+          {accountIds.map(id =>
+            <AccountContainer key={id} id={id} withNote={false} />,
+          )}
+        </ScrollableList>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/report/components/status_check_box.js b/app/javascript/flavours/glitch/features/report/components/status_check_box.js
new file mode 100644
index 000000000..cc49042fc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/report/components/status_check_box.js
@@ -0,0 +1,76 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Toggle from 'react-toggle';
+import noop from 'lodash/noop';
+import StatusContent from 'flavours/glitch/components/status_content';
+import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
+import Bundle from 'flavours/glitch/features/ui/components/bundle';
+
+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;
+    let media = null;
+
+    if (status.get('reblog')) {
+      return null;
+    }
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
+
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        const video = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
+            {Component => (
+              <Component
+                preview={video.get('preview_url')}
+                blurhash={video.get('blurhash')}
+                src={video.get('url')}
+                alt={video.get('description')}
+                width={239}
+                height={110}
+                inline
+                sensitive={status.get('sensitive')}
+                revealed={false}
+                onOpenVideo={noop}
+              />
+            )}
+          </Bundle>
+        );
+      } else {
+        media = (
+          <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery} >
+            {Component => <Component media={status.get('media_attachments')} sensitive={status.get('sensitive')} revealed={false} height={110} onOpenMedia={noop} />}
+          </Bundle>
+        );
+      }
+    }
+
+    return (
+      <div className='status-check-box'>
+        <div className='status-check-box__status'>
+          <StatusContent
+            status={status}
+            media={media}
+          />
+        </div>
+
+        <div className='status-check-box-toggle'>
+          <Toggle checked={checked} onChange={onToggle} disabled={disabled} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js b/app/javascript/flavours/glitch/features/report/containers/status_check_box_container.js
new file mode 100644
index 000000000..9bfd41ffc
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/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/flavours/glitch/features/search/index.js b/app/javascript/flavours/glitch/features/search/index.js
new file mode 100644
index 000000000..b35c8ed49
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/search/index.js
@@ -0,0 +1,17 @@
+import React from 'react';
+import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
+import SearchResultsContainer from 'flavours/glitch/features/compose/containers/search_results_container';
+
+const Search = () => (
+  <div className='column search-page'>
+    <SearchContainer />
+
+    <div className='drawer__pager'>
+      <div className='drawer__inner darker'>
+        <SearchResultsContainer />
+      </div>
+    </div>
+  </div>
+);
+
+export default Search;
diff --git a/app/javascript/flavours/glitch/features/standalone/compose/index.js b/app/javascript/flavours/glitch/features/standalone/compose/index.js
new file mode 100644
index 000000000..b33c21cb5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/compose/index.js
@@ -0,0 +1,20 @@
+import React from 'react';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import NotificationsContainer from 'flavours/glitch/features/ui/containers/notifications_container';
+import LoadingBarContainer from 'flavours/glitch/features/ui/containers/loading_bar_container';
+import ModalContainer from 'flavours/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/flavours/glitch/features/standalone/hashtag_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
new file mode 100644
index 000000000..629f5c2ea
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/hashtag_timeline/index.js
@@ -0,0 +1,90 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { expandHashtagTimeline } from 'flavours/glitch/actions/timelines';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList } from 'immutable';
+import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+
+const mapStateToProps = (state, { hashtag }) => ({
+  statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
+  isLoading: state.getIn(['timelines', `hashtag:${hashtag}`, 'isLoading'], false),
+  hasMore: state.getIn(['timelines', `hashtag:${hashtag}`, 'hasMore'], false),
+});
+
+export default @connect(mapStateToProps)
+class HashtagTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    hashtag: PropTypes.string.isRequired,
+    local: PropTypes.bool.isRequired,
+  };
+
+  static defaultProps = {
+    local: false,
+  };
+
+  componentDidMount () {
+    const { dispatch, hashtag, local } = this.props;
+
+    dispatch(expandHashtagTimeline(hashtag, { local }));
+  }
+
+  handleLoadMore = () => {
+    const { dispatch, hashtag, local, statusIds } = this.props;
+    const maxId = statusIds.last();
+
+    if (maxId) {
+      dispatch(expandHashtagTimeline(hashtag, { maxId, local }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
+  }
+
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
+  render () {
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
+
+    return (
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
new file mode 100644
index 000000000..5f8a369ff
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -0,0 +1,100 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
+
+const mapStateToProps = (state, { local }) => {
+  const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
+
+  return {
+    statusIds: timeline.get('items', ImmutableList()),
+    isLoading: timeline.get('isLoading', false),
+    hasMore: timeline.get('hasMore', false),
+  };
+};
+
+export default @connect(mapStateToProps)
+class PublicTimeline extends React.PureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    local: PropTypes.bool,
+  };
+
+  componentDidMount () {
+    this._connect();
+  }
+
+  componentDidUpdate (prevProps) {
+    if (prevProps.local !== this.props.local) {
+      this._disconnect();
+      this._connect();
+    }
+  }
+
+  _connect () {
+    const { dispatch, local } = this.props;
+
+    dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
+  }
+ 
+  handleLoadMore = () => {
+    const { dispatch, statusIds, local } = this.props;
+    const maxId = statusIds.last();
+
+    if (maxId) {
+      dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
+  }
+
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
+  render () {
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
+
+    return (
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
new file mode 100644
index 000000000..080362dd0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -0,0 +1,217 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
+import { defineMessages, injectIntl } from 'react-intl';
+import { me, isStaff } from 'flavours/glitch/util/initial_state';
+import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links';
+
+const messages = defineMessages({
+  delete: { id: 'status.delete', defaultMessage: 'Delete' },
+  redraft: { id: 'status.redraft', defaultMessage: 'Delete & re-draft' },
+  direct: { id: 'status.direct', defaultMessage: 'Direct message @{name}' },
+  mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' },
+  reply: { id: 'status.reply', defaultMessage: 'Reply' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+  reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost to original audience' },
+  cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+  bookmark: { id: 'status.bookmark', defaultMessage: 'Bookmark' },
+  more: { id: 'status.more', defaultMessage: 'More' },
+  mute: { id: 'status.mute', defaultMessage: 'Mute @{name}' },
+  muteConversation: { id: 'status.mute_conversation', defaultMessage: 'Mute conversation' },
+  unmuteConversation: { id: 'status.unmute_conversation', defaultMessage: 'Unmute conversation' },
+  block: { id: 'status.block', defaultMessage: 'Block @{name}' },
+  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' },
+  admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
+  admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
+  copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+});
+
+export default @injectIntl
+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,
+    onBookmark: PropTypes.func.isRequired,
+    onMute: PropTypes.func,
+    onMuteConversation: PropTypes.func,
+    onBlock: PropTypes.func,
+    onDelete: PropTypes.func.isRequired,
+    onDirect: 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 = (e) => {
+    this.props.onFavourite(this.props.status, e);
+  }
+
+  handleBookmarkClick = (e) => {
+    this.props.onBookmark(this.props.status, e);
+  }
+
+  handleDeleteClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history);
+  }
+
+  handleRedraftClick = () => {
+    this.props.onDelete(this.props.status, this.context.router.history, true);
+  }
+
+  handleDirectClick = () => {
+    this.props.onDirect(this.props.status.get('account'), this.context.router.history);
+  }
+
+  handleMentionClick = () => {
+    this.props.onMention(this.props.status.get('account'), this.context.router.history);
+  }
+
+  handleMuteClick = () => {
+    this.props.onMute(this.props.status.get('account'));
+  }
+
+  handleConversationMuteClick = () => {
+    this.props.onMuteConversation(this.props.status);
+  }
+
+  handleBlockClick = () => {
+    this.props.onBlock(this.props.status);
+  }
+
+  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);
+  }
+
+  handleCopy = () => {
+    const url      = this.props.status.get('url');
+    const textarea = document.createElement('textarea');
+
+    textarea.textContent    = url;
+    textarea.style.position = 'fixed';
+
+    document.body.appendChild(textarea);
+
+    try {
+      textarea.select();
+      document.execCommand('copy');
+    } catch (e) {
+
+    } finally {
+      document.body.removeChild(textarea);
+    }
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
+    const mutingConversation = status.get('muted');
+
+    let menu = [];
+
+    if (publicStatus) {
+      menu.push({ text: intl.formatMessage(messages.copy), action: this.handleCopy });
+      menu.push({ text: intl.formatMessage(messages.embed), action: this.handleEmbed });
+      menu.push(null);
+    }
+
+    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(null);
+      menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDeleteClick });
+      menu.push({ text: intl.formatMessage(messages.redraft), action: this.handleRedraftClick });
+    } else {
+      menu.push({ text: intl.formatMessage(messages.mention, { name: status.getIn(['account', 'username']) }), action: this.handleMentionClick });
+      menu.push({ text: intl.formatMessage(messages.direct, { name: status.getIn(['account', 'username']) }), action: this.handleDirectClick });
+      menu.push(null);
+      menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
+      menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
+      menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
+      if (isStaff && (accountAdminLink || statusAdminLink)) {
+        menu.push(null);
+        if (accountAdminLink !== undefined) {
+          menu.push({
+            text: intl.formatMessage(messages.admin_account, { name: status.getIn(['account', 'username']) }),
+            href: accountAdminLink(status.getIn(['account', 'id'])),
+          });
+        }
+        if (statusAdminLink !== undefined) {
+          menu.push({
+            text: intl.formatMessage(messages.admin_status),
+            href: statusAdminLink(status.getIn(['account', 'id']), status.get('id')),
+          });
+        }
+      }
+    }
+
+    const shareButton = ('share' in navigator) && publicStatus && (
+      <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' && me !== status.getIn(['account', 'id'])));
+    let reblog_message  = status.get('visibility') === 'private' ? messages.reblog_private : messages.reblog;
+
+    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(reblog_message)} icon={reblogIcon} onClick={this.handleReblogClick} /></div>
+        <div className='detailed-status__button'><IconButton className='star-icon' animate active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /></div>
+        {shareButton}
+        <div className='detailed-status__button'><IconButton className='bookmark-icon' active={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} /></div>
+
+        <div className='detailed-status__action-bar-dropdown'>
+          <DropdownMenuContainer size={18} icon='ellipsis-h' items={menu} direction='left' title={intl.formatMessage(messages.more)} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/card.js b/app/javascript/flavours/glitch/features/status/components/card.js
new file mode 100644
index 000000000..03867e03a
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/card.js
@@ -0,0 +1,277 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Immutable from 'immutable';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { FormattedMessage } from 'react-intl';
+import punycode from 'punycode';
+import classnames from 'classnames';
+import { decode as decodeIDNA } from 'flavours/glitch/util/idna';
+import Icon from 'flavours/glitch/components/icon';
+import classNames from 'classnames';
+import { useBlurhash } from 'flavours/glitch/util/initial_state';
+import { decode } from 'blurhash';
+
+const getHostname = url => {
+  const parser = document.createElement('a');
+  parser.href = url;
+  return parser.hostname;
+};
+
+const trim = (text, len) => {
+  const cut = text.indexOf(' ', len);
+
+  if (cut === -1) {
+    return text;
+  }
+
+  return text.substring(0, cut) + (text.length > len ? '…' : '');
+};
+
+const domParser = new DOMParser();
+
+const addAutoPlay = html => {
+  const document = domParser.parseFromString(html, 'text/html').documentElement;
+  const iframe = document.querySelector('iframe');
+
+  if (iframe) {
+    if (iframe.src.indexOf('?') !== -1) {
+      iframe.src += '&';
+    } else {
+      iframe.src += '?';
+    }
+
+    iframe.src += 'autoplay=1&auto_play=1';
+
+    // DOM parser creates html/body elements around original HTML fragment,
+    // so we need to get innerHTML out of the body and not the entire document
+    return document.querySelector('body').innerHTML;
+  }
+
+  return html;
+};
+
+export default class Card extends React.PureComponent {
+
+  static propTypes = {
+    card: ImmutablePropTypes.map,
+    maxDescription: PropTypes.number,
+    onOpenMedia: PropTypes.func.isRequired,
+    compact: PropTypes.bool,
+    defaultWidth: PropTypes.number,
+    cacheWidth: PropTypes.func,
+    sensitive: PropTypes.bool,
+  };
+
+  static defaultProps = {
+    maxDescription: 50,
+    compact: false,
+  };
+
+  state = {
+    width: this.props.defaultWidth || 280,
+    previewLoaded: false,
+    embedded: false,
+    revealed: !this.props.sensitive,
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!Immutable.is(this.props.card, nextProps.card)) {
+      this.setState({ embedded: false, previewLoaded: false });
+    }
+    if (this.props.sensitive !== nextProps.sensitive) {
+      this.setState({ revealed: !nextProps.sensitive });
+    }
+  }
+
+  componentDidMount () {
+    if (this.props.card && this.props.card.get('blurhash')) {
+      this._decode();
+    }
+  }
+
+  componentDidUpdate (prevProps) {
+    const { card } = this.props;
+    if (card.get('blurhash') && (!prevProps.card || prevProps.card.get('blurhash') !== card.get('blurhash'))) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    if (!useBlurhash) return;
+
+    const hash   = this.props.card.get('blurhash');
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
+  handlePhotoClick = () => {
+    const { card, onOpenMedia } = this.props;
+
+    onOpenMedia(
+      Immutable.fromJS([
+        {
+          type: 'image',
+          url: card.get('embed_url'),
+          description: card.get('title'),
+          meta: {
+            original: {
+              width: card.get('width'),
+              height: card.get('height'),
+            },
+          },
+        },
+      ]),
+      0,
+    );
+  };
+
+  handleEmbedClick = () => {
+    const { card } = this.props;
+
+    if (card.get('type') === 'photo') {
+      this.handlePhotoClick();
+    } else {
+      this.setState({ embedded: true });
+    }
+  }
+
+  setRef = c => {
+    if (c) {
+      if (this.props.cacheWidth) this.props.cacheWidth(c.offsetWidth);
+      this.setState({ width: c.offsetWidth });
+    }
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+  }
+
+  handleImageLoad = () => {
+    this.setState({ previewLoaded: true });
+  }
+
+  handleReveal = () => {
+    this.setState({ revealed: true });
+  }
+
+  renderVideo () {
+    const { card }  = this.props;
+    const content   = { __html: addAutoPlay(card.get('html')) };
+    const { width } = this.state;
+    const ratio     = card.get('width') / card.get('height');
+    const height    = width / ratio;
+
+    return (
+      <div
+        ref={this.setRef}
+        className='status-card__image status-card-video'
+        dangerouslySetInnerHTML={content}
+        style={{ height }}
+      />
+    );
+  }
+
+  render () {
+    const { card, maxDescription, compact, defaultWidth } = this.props;
+    const { width, embedded, revealed } = this.state;
+
+    if (card === null) {
+      return null;
+    }
+
+    const provider    = card.get('provider_name').length === 0 ? decodeIDNA(getHostname(card.get('url'))) : card.get('provider_name');
+    const horizontal  = (!compact && card.get('width') > card.get('height') && (card.get('width') + 100 >= width)) || card.get('type') !== 'link' || embedded;
+    const interactive = card.get('type') !== 'link';
+    const className   = classnames('status-card', { horizontal, compact, interactive });
+    const title       = interactive ? <a className='status-card__title' href={card.get('url')} title={card.get('title')} rel='noopener noreferrer' target='_blank'><strong>{card.get('title')}</strong></a> : <strong className='status-card__title' title={card.get('title')}>{card.get('title')}</strong>;
+    const ratio       = card.get('width') / card.get('height');
+    const height      = (compact && !embedded) ? (width / (16 / 9)) : (width / ratio);
+
+    const description = (
+      <div className={classNames('status-card__content', { 'status-card__content--blurred': !revealed })}>
+        {title}
+        {!(horizontal || compact) && <p className='status-card__description'>{trim(card.get('description') || '', maxDescription)}</p>}
+        <span className='status-card__host'>{provider}</span>
+      </div>
+    );
+
+    let embed     = '';
+    let canvas = <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('status-card__image-preview', { 'status-card__image-preview--hidden' : revealed && this.state.previewLoaded })} />;
+    let thumbnail = <img src={card.get('image')} alt='' style={{ width: horizontal ? width : null, height: horizontal ? height : null, visibility: revealed ? null : 'hidden' }} onLoad={this.handleImageLoad} className='status-card__image-image' />;
+    let spoilerButton = (
+      <button type='button' onClick={this.handleReveal} className='spoiler-button__overlay'>
+        <span className='spoiler-button__overlay__label'><FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /></span>
+      </button>
+    );
+    spoilerButton = (
+      <div className={classNames('spoiler-button', { 'spoiler-button--minified': revealed })}>
+        {spoilerButton}
+      </div>
+    );
+
+    if (interactive) {
+      if (embedded) {
+        embed = this.renderVideo();
+      } else {
+        let iconVariant = 'play';
+
+        if (card.get('type') === 'photo') {
+          iconVariant = 'search-plus';
+        }
+
+        embed = (
+          <div className='status-card__image'>
+            {canvas}
+            {thumbnail}
+
+            {revealed && (
+              <div className='status-card__actions'>
+                <div>
+                  <button onClick={this.handleEmbedClick}><Icon id={iconVariant} /></button>
+                  {horizontal && <a href={card.get('url')} target='_blank' rel='noopener noreferrer'><Icon id='external-link' /></a>}
+                </div>
+              </div>
+            )}
+            {!revealed && spoilerButton}
+          </div>
+        );
+      }
+
+      return (
+        <div className={className} ref={this.setRef}>
+          {embed}
+          {!compact && description}
+        </div>
+      );
+    } else if (card.get('image')) {
+      embed = (
+        <div className='status-card__image'>
+          {canvas}
+          {thumbnail}
+          {!revealed && spoilerButton}
+        </div>
+      );
+    } else {
+      embed = (
+        <div className='status-card__image'>
+          <Icon id='file-text' />
+          {!revealed && spoilerButton}
+        </div>
+      );
+    }
+
+    return (
+      <a href={card.get('url')} className={className} target='_blank' rel='noopener noreferrer' ref={this.setRef}>
+        {embed}
+        {description}
+      </a>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
new file mode 100644
index 000000000..4fbd65517
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -0,0 +1,275 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import Avatar from 'flavours/glitch/components/avatar';
+import DisplayName from 'flavours/glitch/components/display_name';
+import StatusContent from 'flavours/glitch/components/status_content';
+import MediaGallery from 'flavours/glitch/components/media_gallery';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import { Link } from 'react-router-dom';
+import { FormattedDate } from 'react-intl';
+import Card from './card';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
+import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
+import classNames from 'classnames';
+import PollContainer from 'flavours/glitch/containers/poll_container';
+import Icon from 'flavours/glitch/components/icon';
+import AnimatedNumber from 'flavours/glitch/components/animated_number';
+
+export default class DetailedStatus extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    settings: ImmutablePropTypes.map.isRequired,
+    onOpenMedia: PropTypes.func.isRequired,
+    onOpenVideo: PropTypes.func.isRequired,
+    onToggleHidden: PropTypes.func,
+    expanded: PropTypes.bool,
+    measureHeight: PropTypes.bool,
+    onHeightChange: PropTypes.func,
+    domain: PropTypes.string.isRequired,
+    compact: PropTypes.bool,
+    showMedia: PropTypes.bool,
+    onToggleMediaVisibility: PropTypes.func,
+  };
+
+  state = {
+    height: null,
+  };
+
+  handleAccountClick = (e) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    }
+
+    e.stopPropagation();
+  }
+
+  parseClick = (e, destination) => {
+    if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey) && this.context.router) {
+      e.preventDefault();
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(destination, state);
+    }
+
+    e.stopPropagation();
+  }
+
+  handleOpenVideo = (media, options) => {
+    this.props.onOpenVideo(media, options);
+  }
+
+  _measureHeight (heightJustChanged) {
+    if (this.props.measureHeight && this.node) {
+      scheduleIdleTask(() => this.node && this.setState({ height: Math.ceil(this.node.scrollHeight) + 1 }));
+
+      if (this.props.onHeightChange && heightJustChanged) {
+        this.props.onHeightChange();
+      }
+    }
+  }
+
+  setRef = c => {
+    this.node = c;
+    this._measureHeight();
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    this._measureHeight(prevState.height !== this.state.height);
+  }
+
+  handleChildUpdate = () => {
+    this._measureHeight();
+  }
+
+  handleModalLink = e => {
+    e.preventDefault();
+
+    let href;
+
+    if (e.target.nodeName !== 'A') {
+      href = e.target.parentNode.href;
+    } else {
+      href = e.target.href;
+    }
+
+    window.open(href, 'mastodon-intent', 'width=445,height=600,resizable=no,menubar=no,status=no,scrollbars=yes');
+  }
+
+  render () {
+    const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
+    const { expanded, onToggleHidden, settings } = this.props;
+    const outerStyle = { boxSizing: 'border-box' };
+    const { compact } = this.props;
+
+    if (!status) {
+      return null;
+    }
+
+    let media           = null;
+    let mediaIcon       = null;
+    let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
+    let favouriteLink = '';
+
+    if (this.props.measureHeight) {
+      outerStyle.height = `${this.state.height}px`;
+    }
+
+    if (status.get('poll')) {
+      media = <PollContainer pollId={status.get('poll')} />;
+      mediaIcon = 'tasks';
+    } else 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']) === 'audio') {
+        const attachment = status.getIn(['media_attachments', 0]);
+
+        media = (
+          <Audio
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
+            height={110}
+            preload
+          />
+        );
+        mediaIcon = 'music';
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        const attachment = status.getIn(['media_attachments', 0]);
+        media = (
+          <Video
+            preview={attachment.get('preview_url')}
+            blurhash={attachment.get('blurhash')}
+            src={attachment.get('url')}
+            alt={attachment.get('description')}
+            inline
+            sensitive={status.get('sensitive')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            preventPlayback={!expanded}
+            onOpenVideo={this.handleOpenVideo}
+            autoplay
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />
+        );
+        mediaIcon = 'video-camera';
+      } else {
+        media = (
+          <MediaGallery
+            standalone
+            sensitive={status.get('sensitive')}
+            media={status.get('media_attachments')}
+            letterbox={settings.getIn(['media', 'letterbox'])}
+            fullwidth={settings.getIn(['media', 'fullwidth'])}
+            hidden={!expanded}
+            onOpenMedia={this.props.onOpenMedia}
+            visible={this.props.showMedia}
+            onToggleVisibility={this.props.onToggleMediaVisibility}
+          />
+        );
+        mediaIcon = 'picture-o';
+      }
+    } else if (status.get('card')) {
+      media = <Card sensitive={status.get('sensitive')} onOpenMedia={this.props.onOpenMedia} card={status.get('card')} />;
+      mediaIcon = 'link';
+    }
+
+    if (status.get('application')) {
+      applicationLink = <span> · <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener noreferrer'>{status.getIn(['application', 'name'])}</a></span>;
+    }
+
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (!['unlisted', 'public'].includes(status.get('visibility'))) {
+      reblogLink = null;
+    } else if (this.context.router) {
+      reblogLink = (
+        <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+          <Icon id={reblogIcon} />
+          <span className='detailed-status__reblogs'>
+            <AnimatedNumber value={status.get('reblogs_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      reblogLink = (
+        <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <Icon id={reblogIcon} />
+          <span className='detailed-status__reblogs'>
+            <AnimatedNumber value={status.get('reblogs_count')} />
+          </span>
+        </a>
+      );
+    }
+
+    if (this.context.router) {
+      favouriteLink = (
+        <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </Link>
+      );
+    } else {
+      favouriteLink = (
+        <a href={`/interact/${status.get('id')}?type=favourite`} className='detailed-status__link' onClick={this.handleModalLink}>
+          <Icon id='star' />
+          <span className='detailed-status__favorites'>
+            <AnimatedNumber value={status.get('favourites_count')} />
+          </span>
+        </a>
+      );
+    }
+
+    return (
+      <div style={outerStyle}>
+        <div ref={this.setRef} className={classNames('detailed-status', { compact })} data-status-by={status.getIn(['account', 'acct'])}>
+          <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')} localDomain={this.props.domain} />
+          </a>
+
+          <StatusContent
+            status={status}
+            media={media}
+            mediaIcon={mediaIcon}
+            expanded={expanded}
+            collapsed={false}
+            onExpandedToggle={onToggleHidden}
+            parseClick={this.parseClick}
+            onUpdate={this.handleChildUpdate}
+            tagLinks={settings.get('tag_misleading_links')}
+            rewriteMentions={settings.get('rewrite_mentions')}
+            disabled
+          />
+
+          <div className='detailed-status__meta'>
+            <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener noreferrer'>
+              <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 && ['·', reblogLink]} · {favouriteLink} · <VisibilityIcon visibility={status.get('visibility')} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
new file mode 100644
index 000000000..9d11f37e0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/containers/detailed_status_container.js
@@ -0,0 +1,160 @@
+import { connect } from 'react-redux';
+import DetailedStatus from '../components/detailed_status';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import {
+  reblog,
+  favourite,
+  unreblog,
+  unfavourite,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+} from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import { boostModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { showAlertForError } from 'flavours/glitch/actions/alerts';
+
+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?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const mapStateToProps = (state, props) => ({
+    status: getStatus(state, props),
+    domain: state.getIn(['meta', 'domain']),
+    settings: state.get('local_settings'),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+
+  onReply (status, router) {
+    dispatch((_, getState) => {
+      let state = getState();
+      if (state.getIn(['compose', 'text']).trim().length !== 0) {
+        dispatch(openModal('CONFIRM', {
+          message: intl.formatMessage(messages.replyMessage),
+          confirm: intl.formatMessage(messages.replyConfirm),
+          onConfirm: () => dispatch(replyCompose(status, router)),
+        }));
+      } else {
+        dispatch(replyCompose(status, router));
+      }
+    });
+  },
+
+  onModalReblog (status) {
+    dispatch(reblog(status));
+  },
+
+  onReblog (status, e) {
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      if (e.shiftKey || !boostModal) {
+        this.onModalReblog(status);
+      } else {
+        dispatch(openModal('BOOST', { status, onReblog: this.onModalReblog }));
+      }
+    }
+  },
+
+  onFavourite (status) {
+    if (status.get('favourited')) {
+      dispatch(unfavourite(status));
+    } else {
+      dispatch(favourite(status));
+    }
+  },
+
+  onPin (status) {
+    if (status.get('pinned')) {
+      dispatch(unpin(status));
+    } else {
+      dispatch(pin(status));
+    }
+  },
+
+  onEmbed (status) {
+    dispatch(openModal('EMBED', {
+      url: status.get('url'),
+      onError: error => dispatch(showAlertForError(error)),
+    }));
+  },
+
+  onDelete (status, history, withRedraft = false) {
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  },
+
+  onDirect (account, router) {
+    dispatch(directCompose(account, router));
+  },
+
+  onMention (account, router) {
+    dispatch(mentionCompose(account, router));
+  },
+
+  onOpenMedia (media, index) {
+    dispatch(openModal('MEDIA', { media, index }));
+  },
+
+  onOpenVideo (media, options) {
+    dispatch(openModal('VIDEO', { media, options }));
+  },
+
+  onBlock (status) {
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  },
+
+  onReport (status) {
+    dispatch(initReport(status.get('account'), status));
+  },
+
+  onMute (account) {
+    dispatch(initMuteModal(account));
+  },
+
+  onMuteConversation (status) {
+    if (status.get('muted')) {
+      dispatch(unmuteStatus(status.get('id')));
+    } else {
+      dispatch(muteStatus(status.get('id')));
+    }
+  },
+
+});
+
+export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(DetailedStatus));
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
new file mode 100644
index 000000000..a9abc545e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -0,0 +1,610 @@
+import Immutable from 'immutable';
+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 { createSelector } from 'reselect';
+import { fetchStatus } from 'flavours/glitch/actions/statuses';
+import MissingIndicator from 'flavours/glitch/components/missing_indicator';
+import DetailedStatus from './components/detailed_status';
+import ActionBar from './components/action_bar';
+import Column from 'flavours/glitch/features/ui/components/column';
+import {
+  favourite,
+  unfavourite,
+  bookmark,
+  unbookmark,
+  reblog,
+  unreblog,
+  pin,
+  unpin,
+} from 'flavours/glitch/actions/interactions';
+import {
+  replyCompose,
+  mentionCompose,
+  directCompose,
+} from 'flavours/glitch/actions/compose';
+import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
+import { muteStatus, unmuteStatus, deleteStatus } from 'flavours/glitch/actions/statuses';
+import { initMuteModal } from 'flavours/glitch/actions/mutes';
+import { initBlockModal } from 'flavours/glitch/actions/blocks';
+import { initReport } from 'flavours/glitch/actions/reports';
+import { makeGetStatus } from 'flavours/glitch/selectors';
+import { ScrollContainer } from 'react-router-scroll-4';
+import ColumnBackButton from 'flavours/glitch/components/column_back_button';
+import ColumnHeader from '../../components/column_header';
+import StatusContainer from 'flavours/glitch/containers/status_container';
+import { openModal } from 'flavours/glitch/actions/modal';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from 'flavours/glitch/util/fullscreen';
+import { autoUnfoldCW } from 'flavours/glitch/util/content_warning';
+import { textForScreenReader, defaultMediaVisibility } from 'flavours/glitch/components/status';
+import Icon from 'flavours/glitch/components/icon';
+
+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?' },
+  redraftConfirm: { id: 'confirmations.redraft.confirm', defaultMessage: 'Delete & redraft' },
+  redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? You will lose all replies, boosts and favourites to it.' },
+  revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
+  hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
+  detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
+  replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
+  replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
+  tootHeading: { id: 'column.toot', defaultMessage: 'Toots and replies' },
+});
+
+const makeMapStateToProps = () => {
+  const getStatus = makeGetStatus();
+
+  const getAncestorsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'inReplyTos']),
+  ], (statusId, inReplyTos) => {
+    let ancestorsIds = Immutable.List();
+    ancestorsIds = ancestorsIds.withMutations(mutable => {
+      let id = statusId;
+
+      while (id) {
+        mutable.unshift(id);
+        id = inReplyTos.get(id);
+      }
+    });
+
+    return ancestorsIds;
+  });
+
+  const getDescendantsIds = createSelector([
+    (_, { id }) => id,
+    state => state.getIn(['contexts', 'replies']),
+    state => state.get('statuses'),
+  ], (statusId, contextReplies, statuses) => {
+    let descendantsIds = [];
+    const ids = [statusId];
+
+    while (ids.length > 0) {
+      let id        = ids.shift();
+      const replies = contextReplies.get(id);
+
+      if (statusId !== id) {
+        descendantsIds.push(id);
+      }
+
+      if (replies) {
+        replies.reverse().forEach(reply => {
+          ids.unshift(reply);
+        });
+      }
+    }
+
+    let insertAt = descendantsIds.findIndex((id) => statuses.get(id).get('in_reply_to_account_id') !== statuses.get(id).get('account'));
+    if (insertAt !== -1) {
+      descendantsIds.forEach((id, idx) => {
+        if (idx > insertAt && statuses.get(id).get('in_reply_to_account_id') === statuses.get(id).get('account')) {
+          descendantsIds.splice(idx, 1);
+          descendantsIds.splice(insertAt, 0, id);
+          insertAt += 1;
+        }
+      });
+    }
+
+    return Immutable.List(descendantsIds);
+  });
+
+  const mapStateToProps = (state, props) => {
+    const status = getStatus(state, { id: props.params.statusId });
+    let ancestorsIds = Immutable.List();
+    let descendantsIds = Immutable.List();
+
+    if (status) {
+      ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
+      descendantsIds = getDescendantsIds(state, { id: status.get('id') });
+    }
+
+    return {
+      status,
+      ancestorsIds,
+      descendantsIds,
+      settings: state.get('local_settings'),
+      askReplyConfirmation: state.getIn(['local_settings', 'confirm_before_clearing_draft']) && state.getIn(['compose', 'text']).trim().length !== 0,
+      domain: state.getIn(['meta', 'domain']),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+export default @injectIntl
+@connect(makeMapStateToProps)
+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,
+    askReplyConfirmation: PropTypes.bool,
+    multiColumn: PropTypes.bool,
+    domain: PropTypes.string.isRequired,
+  };
+
+  state = {
+    fullscreen: false,
+    isExpanded: undefined,
+    threadExpanded: undefined,
+    statusId: undefined,
+    loadedStatusId: undefined,
+    showMedia: undefined,
+    revealBehindCW: undefined,
+  };
+
+  componentDidMount () {
+    attachFullscreenListener(this.onFullScreenChange);
+    this.props.dispatch(fetchStatus(this.props.params.statusId));
+
+    const { status, ancestorsIds } = this.props;
+
+    if (status && ancestorsIds && ancestorsIds.size > 0) {
+      const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+      window.requestAnimationFrame(() => {
+        element.scrollIntoView(true);
+      });
+    }
+  }
+
+  static getDerivedStateFromProps(props, state) {
+    let update = {};
+    let updated = false;
+
+    if (props.params.statusId && state.statusId !== props.params.statusId) {
+      props.dispatch(fetchStatus(props.params.statusId));
+      update.threadExpanded = undefined;
+      update.statusId = props.params.statusId;
+      updated = true;
+    }
+
+    const revealBehindCW = props.settings.getIn(['media', 'reveal_behind_cw']);
+    if (revealBehindCW !== state.revealBehindCW) {
+      update.revealBehindCW = revealBehindCW;
+      if (revealBehindCW) update.showMedia = defaultMediaVisibility(props.status, props.settings);
+      updated = true;
+    }
+
+    if (props.status && state.loadedStatusId !== props.status.get('id')) {
+      update.showMedia = defaultMediaVisibility(props.status, props.settings);
+      update.loadedStatusId = props.status.get('id');
+      update.isExpanded = autoUnfoldCW(props.settings, props.status);
+      updated = true;
+    }
+
+    return updated ? update : null;
+  }
+
+  handleExpandedToggle = () => {
+    if (this.props.status.get('spoiler_text')) {
+      this.setExpansion(!this.state.isExpanded);
+    }
+  };
+
+  handleToggleMediaVisibility = () => {
+    this.setState({ showMedia: !this.state.showMedia });
+  }
+
+  handleModalFavourite = (status) => {
+    this.props.dispatch(favourite(status));
+  }
+
+  handleFavouriteClick = (status, e) => {
+    if (status.get('favourited')) {
+      this.props.dispatch(unfavourite(status));
+    } else {
+      if ((e && e.shiftKey) || !favouriteModal) {
+        this.handleModalFavourite(status);
+      } else {
+        this.props.dispatch(openModal('FAVOURITE', { status, onFavourite: this.handleModalFavourite }));
+      }
+    }
+  }
+
+  handlePin = (status) => {
+    if (status.get('pinned')) {
+      this.props.dispatch(unpin(status));
+    } else {
+      this.props.dispatch(pin(status));
+    }
+  }
+
+  handleReplyClick = (status) => {
+    let { askReplyConfirmation, dispatch, intl } = this.props;
+    if (askReplyConfirmation) {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(messages.replyMessage),
+        confirm: intl.formatMessage(messages.replyConfirm),
+        onDoNotAsk: () => dispatch(changeLocalSetting(['confirm_before_clearing_draft'], false)),
+        onConfirm: () => dispatch(replyCompose(status, this.context.router.history)),
+      }));
+    } else {
+      dispatch(replyCompose(status, this.context.router.history));
+    }
+  }
+
+  handleModalReblog = (status) => {
+    const { dispatch } = this.props;
+
+    if (status.get('reblogged')) {
+      dispatch(unreblog(status));
+    } else {
+      dispatch(reblog(status));
+    }
+  }
+
+  handleReblogClick = (status, e) => {
+    const { settings, dispatch } = this.props;
+
+    if (settings.get('confirm_boost_missing_media_description') && status.get('media_attachments').some(item => !item.get('description')) && !status.get('reblogged')) {
+      dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog, missingMediaDescription: true }));
+    } else if ((e && e.shiftKey) || !boostModal) {
+      this.handleModalReblog(status);
+    } else {
+      dispatch(openModal('BOOST', { status, onReblog: this.handleModalReblog }));
+    }
+  }
+
+  handleBookmarkClick = (status) => {
+    if (status.get('bookmarked')) {
+      this.props.dispatch(unbookmark(status));
+    } else {
+      this.props.dispatch(bookmark(status));
+    }
+  }
+
+  handleDeleteClick = (status, history, withRedraft = false) => {
+    const { dispatch, intl } = this.props;
+
+    if (!deleteModal) {
+      dispatch(deleteStatus(status.get('id'), history, withRedraft));
+    } else {
+      dispatch(openModal('CONFIRM', {
+        message: intl.formatMessage(withRedraft ? messages.redraftMessage : messages.deleteMessage),
+        confirm: intl.formatMessage(withRedraft ? messages.redraftConfirm : messages.deleteConfirm),
+        onConfirm: () => dispatch(deleteStatus(status.get('id'), history, withRedraft)),
+      }));
+    }
+  }
+
+  handleDirectClick = (account, router) => {
+    this.props.dispatch(directCompose(account, router));
+  }
+
+  handleMentionClick = (account, router) => {
+    this.props.dispatch(mentionCompose(account, router));
+  }
+
+  handleOpenMedia = (media, index) => {
+    this.props.dispatch(openModal('MEDIA', { media, index }));
+  }
+
+  handleOpenVideo = (media, options) => {
+    this.props.dispatch(openModal('VIDEO', { media, options }));
+  }
+
+  handleHotkeyOpenMedia = e => {
+    const { status } = this.props;
+
+    e.preventDefault();
+
+    if (status.get('media_attachments').size > 0) {
+      if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
+        // TODO: toggle play/paused?
+      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
+        this.handleOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
+      } else {
+        this.handleOpenMedia(status.get('media_attachments'), 0);
+      }
+    }
+  }
+
+  handleMuteClick = (account) => {
+    this.props.dispatch(initMuteModal(account));
+  }
+
+  handleConversationMuteClick = (status) => {
+    if (status.get('muted')) {
+      this.props.dispatch(unmuteStatus(status.get('id')));
+    } else {
+      this.props.dispatch(muteStatus(status.get('id')));
+    }
+  }
+
+  handleToggleAll = () => {
+    const { isExpanded } = this.state;
+    this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
+  }
+
+  handleBlockClick = (status) => {
+    const { dispatch } = this.props;
+    const account = status.get('account');
+    dispatch(initBlockModal(account));
+  }
+
+  handleReport = (status) => {
+    this.props.dispatch(initReport(status.get('account'), status));
+  }
+
+  handleEmbed = (status) => {
+    this.props.dispatch(openModal('EMBED', { url: status.get('url') }));
+  }
+
+  handleHotkeyToggleSensitive = () => {
+    this.handleToggleMediaVisibility();
+  }
+
+  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);
+  }
+
+  handleHotkeyBookmark = () => {
+    this.handleBookmarkClick(this.props.status);
+  }
+
+  handleHotkeyMention = e => {
+    e.preventDefault();
+    this.handleMentionClick(this.props.status);
+  }
+
+  handleHotkeyOpenProfile = () => {
+    let state = {...this.context.router.history.location.state};
+    state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+    this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+  }
+
+  handleMoveUp = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size - 1, true);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index, true);
+      } else {
+        this._selectChild(index - 1, true);
+      }
+    }
+  }
+
+  handleMoveDown = id => {
+    const { status, ancestorsIds, descendantsIds } = this.props;
+
+    if (id === status.get('id')) {
+      this._selectChild(ancestorsIds.size + 1, false);
+    } else {
+      let index = ancestorsIds.indexOf(id);
+
+      if (index === -1) {
+        index = descendantsIds.indexOf(id);
+        this._selectChild(ancestorsIds.size + index + 2, false);
+      } else {
+        this._selectChild(index + 1, false);
+      }
+    }
+  }
+
+  _selectChild (index, align_top) {
+    const container = this.node;
+    const element = container.querySelectorAll('.focusable')[index];
+
+    if (element) {
+      if (align_top && container.scrollTop > element.offsetTop) {
+        element.scrollIntoView(true);
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
+        element.scrollIntoView(false);
+      }
+      element.focus();
+    }
+  }
+
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
+  renderChildren (list) {
+    return list.map(id => (
+      <StatusContainer
+        key={id}
+        id={id}
+        expanded={this.state.threadExpanded}
+        onMoveUp={this.handleMoveUp}
+        onMoveDown={this.handleMoveDown}
+        contextType='thread'
+      />
+    ));
+  }
+
+  setExpansion = value => {
+    this.setState({ isExpanded: value });
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  setColumnRef = c => {
+    this.column = c;
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) {
+      const { status, ancestorsIds } = this.props;
+
+      if (status && ancestorsIds && ancestorsIds.size > 0) {
+        const element = this.node.querySelectorAll('.focusable')[ancestorsIds.size - 1];
+
+        window.requestAnimationFrame(() => {
+          element.scrollIntoView(true);
+        });
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    detachFullscreenListener(this.onFullScreenChange);
+  }
+
+  onFullScreenChange = () => {
+    this.setState({ fullscreen: isFullscreen() });
+  }
+
+  shouldUpdateScroll = (prevRouterProps, { location }) => {
+    if ((((prevRouterProps || {}).location || {}).state || {}).mastodonModalOpen) return false;
+    return !(location.state && location.state.mastodonModalOpen);
+  }
+
+  render () {
+    let ancestors, descendants;
+    const { setExpansion } = this;
+    const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props;
+    const { fullscreen, isExpanded } = this.state;
+
+    if (status === null) {
+      return (
+        <Column>
+          <ColumnBackButton multiColumn={multiColumn} />
+          <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,
+      bookmark: this.handleHotkeyBookmark,
+      mention: this.handleHotkeyMention,
+      openProfile: this.handleHotkeyOpenProfile,
+      toggleSpoiler: this.handleExpandedToggle,
+      toggleSensitive: this.handleHotkeyToggleSensitive,
+      openMedia: this.handleHotkeyOpenMedia,
+    };
+
+    return (
+      <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}>
+        <ColumnHeader
+          icon='comment'
+          title={intl.formatMessage(messages.tootHeading)}
+          onClick={this.handleHeaderClick}
+          showBackButton
+          multiColumn={multiColumn}
+          extraButton={(
+            <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><Icon id={status.get('hidden') ? 'eye-slash' : 'eye'} /></button>
+          )}
+        />
+
+        <ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
+          <div className={classNames('scrollable', 'detailed-status__wrapper', { fullscreen })} ref={this.setRef}>
+            {ancestors}
+
+            <HotKeys handlers={handlers}>
+              <div className='focusable' tabIndex='0' aria-label={textForScreenReader(intl, status, false, !status.get('hidden'))}>
+                <DetailedStatus
+                  key={`details-${status.get('id')}`}
+                  status={status}
+                  settings={settings}
+                  onOpenVideo={this.handleOpenVideo}
+                  onOpenMedia={this.handleOpenMedia}
+                  expanded={isExpanded}
+                  onToggleHidden={this.handleExpandedToggle}
+                  domain={domain}
+                  showMedia={this.state.showMedia}
+                  onToggleMediaVisibility={this.handleToggleMediaVisibility}
+                />
+
+                <ActionBar
+                  key={`action-bar-${status.get('id')}`}
+                  status={status}
+                  onReply={this.handleReplyClick}
+                  onFavourite={this.handleFavouriteClick}
+                  onReblog={this.handleReblogClick}
+                  onBookmark={this.handleBookmarkClick}
+                  onDelete={this.handleDeleteClick}
+                  onDirect={this.handleDirectClick}
+                  onMention={this.handleMentionClick}
+                  onMute={this.handleMuteClick}
+                  onMuteConversation={this.handleConversationMuteClick}
+                  onBlock={this.handleBlockClick}
+                  onReport={this.handleReport}
+                  onPin={this.handlePin}
+                  onEmbed={this.handleEmbed}
+                />
+              </div>
+            </HotKeys>
+
+            {descendants}
+          </div>
+        </ScrollContainer>
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/actions_modal.js b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
new file mode 100644
index 000000000..24169036c
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/actions_modal.js
@@ -0,0 +1,124 @@
+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 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import DisplayName from 'flavours/glitch/components/display_name';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+import Link from 'flavours/glitch/components/link';
+import Toggle from 'react-toggle';
+
+export default class ActionsModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    status: ImmutablePropTypes.map,
+    actions: PropTypes.arrayOf(PropTypes.shape({
+      active: PropTypes.bool,
+      href: PropTypes.string,
+      icon: PropTypes.string,
+      meta: PropTypes.node,
+      name: PropTypes.string,
+      on: PropTypes.bool,
+      onPassiveClick: PropTypes.func,
+      text: PropTypes.node,
+    })),
+  };
+
+  renderAction = (action, i) => {
+    if (action === null) {
+      return <li key={`sep-${i}`} className='dropdown-menu__separator' />;
+    }
+
+    const {
+      active,
+      href,
+      icon,
+      meta,
+      name,
+      on,
+      onClick,
+      onPassiveClick,
+      text,
+    } = action;
+
+    return (
+      <li key={name || i}>
+        <Link
+          className={classNames('link', { active })}
+          href={href}
+          onClick={on !== null && typeof on !== 'undefined' && onPassiveClick || onClick}
+          role={onClick ? 'button' : null}
+        >
+          {function () {
+
+            //  We render a `<Toggle>` if we were provided an `on`
+            //  property, and otherwise show an `<Icon>` if available.
+            switch (true) {
+            case on !== null && typeof on !== 'undefined':
+              return (
+                <Toggle
+                  checked={on}
+                  onChange={onPassiveClick || onClick}
+                />
+              );
+            case !!icon:
+              return (
+                <Icon
+                  className='icon'
+                  fixedWidth
+                  id={icon}
+                />
+              );
+            default:
+              return null;
+            }
+          }()}
+          {meta ? (
+            <div>
+              <strong>{text}</strong>
+              {meta}
+            </div>
+          ) : <div>{text}</div>}
+        </Link>
+      </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 noreferrer'>
+              <RelativeTimestamp timestamp={this.props.status.get('created_at')} />
+            </a>
+          </div>
+
+          <a href={this.props.status.getIn(['account', 'url'])} className='status__display-name' rel='noopener noreferrer'>
+            <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 className={classNames({ 'with-status': !!status })}>
+          {this.props.actions.map(this.renderAction)}
+        </ul>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/audio_modal.js b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js
new file mode 100644
index 000000000..08fbddc91
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/audio_modal.js
@@ -0,0 +1,53 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Audio from 'flavours/glitch/features/audio';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class AudioModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  handleStatusClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    }
+  }
+
+  render () {
+    const { media, status } = this.props;
+
+    return (
+      <div className='modal-root__modal audio-modal'>
+        <div className='audio-modal__container'>
+          <Audio
+            src={media.get('url')}
+            alt={media.get('description')}
+            duration={media.getIn(['meta', 'original', 'duration'], 0)}
+            height={135}
+            preload
+          />
+        </div>
+
+        {status && (
+          <div className={classNames('media-modal__meta')}>
+            <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/block_modal.js b/app/javascript/flavours/glitch/features/ui/components/block_modal.js
new file mode 100644
index 000000000..a07baeaa6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/block_modal.js
@@ -0,0 +1,103 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import { makeGetAccount } from '../../../selectors';
+import Button from '../../../components/button';
+import { closeModal } from '../../../actions/modal';
+import { blockAccount } from '../../../actions/accounts';
+import { initReport } from '../../../actions/reports';
+
+
+const makeMapStateToProps = () => {
+  const getAccount = makeGetAccount();
+
+  const mapStateToProps = state => ({
+    account: getAccount(state, state.getIn(['blocks', 'new', 'account_id'])),
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = dispatch => {
+  return {
+    onConfirm(account) {
+      dispatch(blockAccount(account.get('id')));
+    },
+
+    onBlockAndReport(account) {
+      dispatch(blockAccount(account.get('id')));
+      dispatch(initReport(account));
+    },
+
+    onClose() {
+      dispatch(closeModal());
+    },
+  };
+};
+
+export default @connect(makeMapStateToProps, mapDispatchToProps)
+@injectIntl
+class BlockModal extends React.PureComponent {
+
+  static propTypes = {
+    account: PropTypes.object.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onBlockAndReport: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm(this.props.account);
+  }
+
+  handleSecondary = () => {
+    this.props.onClose();
+    this.props.onBlockAndReport(this.props.account);
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { account } = this.props;
+
+    return (
+      <div className='modal-root__modal block-modal'>
+        <div className='block-modal__container'>
+          <p>
+            <FormattedMessage
+              id='confirmations.block.message'
+              defaultMessage='Are you sure you want to block {name}?'
+              values={{ name: <strong>@{account.get('acct')}</strong> }}
+            />
+          </p>
+        </div>
+
+        <div className='block-modal__action-bar'>
+          <Button onClick={this.handleCancel} className='block-modal__cancel-button'>
+            <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+          </Button>
+          <Button onClick={this.handleSecondary} className='confirmation-modal__secondary-button'>
+            <FormattedMessage id='confirmations.block.block_and_report' defaultMessage='Block & Report' />
+          </Button>
+          <Button onClick={this.handleClick} ref={this.setRef}>
+            <FormattedMessage id='confirmations.block.confirm' defaultMessage='Block' />
+          </Button>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/boost_modal.js b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
new file mode 100644
index 000000000..cd2929fdb
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/boost_modal.js
@@ -0,0 +1,104 @@
+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 'flavours/glitch/components/button';
+import StatusContent from 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import DisplayName from 'flavours/glitch/components/display_name';
+import AttachmentList from 'flavours/glitch/components/attachment_list';
+import Icon from 'flavours/glitch/components/icon';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
+  reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
+});
+
+export default @injectIntl
+class BoostModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onReblog: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    missingMediaDescription: PropTypes.bool,
+    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();
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    }
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { status, missingMediaDescription, intl } = this.props;
+    const buttonText = status.get('reblogged') ? messages.cancel_reblog : messages.reblog;
+
+    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 noreferrer'><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} />
+
+            {status.get('media_attachments').size > 0 && (
+              <AttachmentList
+                compact
+                media={status.get('media_attachments')}
+              />
+            )}
+          </div>
+        </div>
+
+        <div className='boost-modal__action-bar'>
+          <div>
+            { missingMediaDescription ?
+                <FormattedMessage id='boost_modal.missing_description' defaultMessage='This toot contains some media without description' />
+              :
+                <FormattedMessage id='boost_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='retweet' /></span> }} />
+            }
+          </div>
+          <Button text={intl.formatMessage(buttonText)} onClick={this.handleReblog} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/bundle.js b/app/javascript/flavours/glitch/features/ui/components/bundle.js
new file mode 100644
index 000000000..8f0d7b8b1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/bundle.js
@@ -0,0 +1,107 @@
+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;
+
+    if (fetchComponent === undefined) {
+      this.setState({ mod: null });
+      return Promise.resolve();
+    }
+
+    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/flavours/glitch/features/ui/components/bundle_column_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_column_error.js
new file mode 100644
index 000000000..3e979a250
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/glitch/components/column_back_button_slim';
+import IconButton from 'flavours/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/flavours/glitch/features/ui/components/bundle_modal_error.js b/app/javascript/flavours/glitch/features/ui/components/bundle_modal_error.js
new file mode 100644
index 000000000..2c14a1e5c
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/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/flavours/glitch/features/ui/components/column.js b/app/javascript/flavours/glitch/features/ui/components/column.js
new file mode 100644
index 000000000..ab78414e0
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/glitch/util/scroll';
+import { isMobile } from 'flavours/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/flavours/glitch/features/ui/components/column_header.js b/app/javascript/flavours/glitch/features/ui/components/column_header.js
new file mode 100644
index 000000000..528ff73a6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_header.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+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 { icon, type, active, columnHeaderId } = this.props;
+    let iconElement = '';
+
+    if (icon) {
+      iconElement = <Icon id={icon} fixedWidth className='column-header__icon' />;
+    }
+
+    return (
+      <h1 className={classNames('column-header', { active })} id={columnHeaderId || null}>
+        <button onClick={this.handleClick}>
+          {iconElement}
+          {type}
+        </button>
+      </h1>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_link.js b/app/javascript/flavours/glitch/features/ui/components/column_link.js
new file mode 100644
index 000000000..d04b869b6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_link.js
@@ -0,0 +1,51 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Link } from 'react-router-dom';
+import Icon from 'flavours/glitch/components/icon';
+
+const ColumnLink = ({ icon, text, to, onClick, href, method, badge }) => {
+  const badgeElement = typeof badge !== 'undefined' ? <span className='column-link__badge'>{badge}</span> : null;
+
+  if (href) {
+    return (
+      <a href={href} className='column-link' data-method={method}>
+        <Icon id={icon} fixedWidth className='column-link__icon' />
+        {text}
+        {badgeElement}
+      </a>
+    );
+  } else if (to) {
+    return (
+      <Link to={to} className='column-link'>
+        <Icon id={icon} fixedWidth className='column-link__icon' />
+        {text}
+        {badgeElement}
+      </Link>
+    );
+  } else {
+    const handleOnClick = (e) => {
+      e.preventDefault();
+      e.stopPropagation();
+      return onClick(e);
+    }
+    return (
+      <a href='#' onClick={onClick && handleOnClick} className='column-link' tabIndex='0'>
+        <Icon id={icon} fixedWidth className='column-link__icon' />
+        {text}
+        {badgeElement}
+      </a>
+    );
+  }
+};
+
+ColumnLink.propTypes = {
+  icon: PropTypes.string.isRequired,
+  text: PropTypes.string.isRequired,
+  to: PropTypes.string,
+  onClick: PropTypes.func,
+  href: PropTypes.string,
+  method: PropTypes.string,
+  badge: PropTypes.node,
+};
+
+export default ColumnLink;
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_loading.js b/app/javascript/flavours/glitch/features/ui/components/column_loading.js
new file mode 100644
index 000000000..22c00c915
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/column_loading.js
@@ -0,0 +1,30 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import Column from 'flavours/glitch/components/column';
+import ColumnHeader from 'flavours/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} placeholder />
+        <div className='scrollable' />
+      </Column>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/column_subheading.js b/app/javascript/flavours/glitch/features/ui/components/column_subheading.js
new file mode 100644
index 000000000..8160c4aa3
--- /dev/null
+++ b/app/javascript/flavours/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/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
new file mode 100644
index 000000000..2de24bea5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -0,0 +1,240 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl } from 'react-intl';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+import ReactSwipeableViews from 'react-swipeable-views';
+import TabsBar, { links, getIndex, getLink } from './tabs_bar';
+import { Link } from 'react-router-dom';
+
+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,
+  BookmarkedStatuses,
+  ListTimeline,
+  Directory,
+} from 'flavours/glitch/util/async-components';
+import Icon from 'flavours/glitch/components/icon';
+import ComposePanel from './compose_panel';
+import NavigationPanel from './navigation_panel';
+
+import detectPassiveEvents from 'detect-passive-events';
+import { scrollRight } from 'flavours/glitch/util/scroll';
+
+const componentMap = {
+  'COMPOSE': Compose,
+  'HOME': HomeTimeline,
+  'NOTIFICATIONS': Notifications,
+  'PUBLIC': PublicTimeline,
+  'REMOTE': PublicTimeline,
+  'COMMUNITY': CommunityTimeline,
+  'HASHTAG': HashtagTimeline,
+  'DIRECT': DirectTimeline,
+  'FAVOURITES': FavouritedStatuses,
+  'BOOKMARKS': BookmarkedStatuses,
+  'LIST': ListTimeline,
+  'DIRECTORY': Directory,
+};
+
+const shouldHideFAB = path => path.match(/^\/statuses\/|^\/search|^\/getting-started/);
+
+const messages = defineMessages({
+  publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
+});
+
+export default @(component => injectIntl(component, { withRef: true }))
+class ColumnsArea extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object.isRequired,
+  };
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    columns: ImmutablePropTypes.list.isRequired,
+    swipeToChangeColumns: PropTypes.bool,
+    singleColumn: PropTypes.bool,
+    children: PropTypes.node,
+    navbarUnder: PropTypes.bool,
+    openSettings: PropTypes.func,
+  };
+
+  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.isRtlLayout = document.getElementsByTagName('body')[0].classList.contains('rtl');
+
+    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) {
+      const modifier = this.isRtlLayout ? -1 : 1;
+      this._interruptScrollAnimation = scrollRight(this.node, (this.node.scrollWidth - window.innerWidth) * modifier);
+    }
+  }
+
+  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');
+
+    if (!this.state.shouldAnimate && typeof this.pendingIndex === 'number') {
+      this.context.router.history.push(getLink(this.pendingIndex));
+      this.pendingIndex = null;
+    }
+  }
+
+  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 columns-area--mobile' key={index}>
+        {view}
+      </div>
+    );
+  }
+
+  renderLoading = columnId => () => {
+    return columnId === 'COMPOSE' ? <DrawerLoading /> : <ColumnLoading />;
+  }
+
+  renderError = (props) => {
+    return <BundleColumnError {...props} />;
+  }
+
+  render () {
+    const { columns, children, singleColumn, swipeToChangeColumns, intl, navbarUnder, openSettings } = this.props;
+    const { shouldAnimate } = this.state;
+
+    const columnIndex = getIndex(this.context.router.history.location.pathname);
+
+    if (singleColumn) {
+      const floatingActionButton = shouldHideFAB(this.context.router.history.location.pathname) ? null : <Link key='floating-action-button' to='/statuses/new' className='floating-action-button' aria-label={intl.formatMessage(messages.publish)}><Icon id='pencil' /></Link>;
+
+      const content = columnIndex !== -1 ? (
+        <ReactSwipeableViews key='content' hysteresis={0.2} threshold={15} index={columnIndex} onChangeIndex={this.handleSwipe} onTransitionEnd={this.handleAnimationEnd} animateTransitions={shouldAnimate} springConfig={{ duration: '400ms', delay: '0s', easeFunction: 'ease' }} style={{ height: '100%' }} disabled={!swipeToChangeColumns}>
+          {links.map(this.renderView)}
+        </ReactSwipeableViews>
+      ) : (
+        <div key='content' className='columns-area columns-area--mobile'>{children}</div>
+      );
+
+      return (
+        <div className='columns-area__panels'>
+          <div className='columns-area__panels__pane columns-area__panels__pane--compositional'>
+            <div className='columns-area__panels__pane__inner'>
+              <ComposePanel />
+            </div>
+          </div>
+
+          <div className='columns-area__panels__main'>
+            {!navbarUnder && <TabsBar key='tabs' />}
+            {content}
+            {navbarUnder && <TabsBar key='tabs' />}
+          </div>
+
+          <div className='columns-area__panels__pane columns-area__panels__pane--start columns-area__panels__pane--navigational'>
+            <div className='columns-area__panels__pane__inner'>
+              <NavigationPanel onOpenSettings={openSettings} />
+            </div>
+          </div>
+
+          {floatingActionButton}
+        </div>
+      );
+    }
+
+    return (
+      <div className='columns-area' ref={this.setRef}>
+        {columns.map(column => {
+          const params = column.get('params', null) === null ? null : column.get('params').toJS();
+          const other  = params && params.other ? params.other : {};
+
+          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 {...other} />}
+            </BundleContainer>
+          );
+        })}
+
+        {React.Children.map(children, child => React.cloneElement(child, { multiColumn: true }))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/compose_panel.js b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
new file mode 100644
index 000000000..498f09ab6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/compose_panel.js
@@ -0,0 +1,16 @@
+import React from 'react';
+import SearchContainer from 'flavours/glitch/features/compose/containers/search_container';
+import ComposeFormContainer from 'flavours/glitch/features/compose/containers/compose_form_container';
+import NavigationContainer from 'flavours/glitch/features/compose/containers/navigation_container';
+import LinkFooter from './link_footer';
+
+const ComposePanel = () => (
+  <div className='compose-panel'>
+    <SearchContainer openInRoute />
+    <NavigationContainer />
+    <ComposeFormContainer singleColumn />
+    <LinkFooter withHotkeys />
+  </div>
+);
+
+export default ComposePanel;
diff --git a/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
new file mode 100644
index 000000000..47a49c0c7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/confirmation_modal.js
@@ -0,0 +1,81 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { injectIntl, FormattedMessage } from 'react-intl';
+import Button from 'flavours/glitch/components/button';
+
+export default @injectIntl
+class ConfirmationModal extends React.PureComponent {
+
+  static propTypes = {
+    message: PropTypes.node.isRequired,
+    confirm: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onConfirm: PropTypes.func.isRequired,
+    secondary: PropTypes.string,
+    onSecondary: PropTypes.func,
+    onDoNotAsk: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleClick = () => {
+    this.props.onClose();
+    this.props.onConfirm();
+    if (this.props.onDoNotAsk && this.doNotAskCheckbox.checked) {
+      this.props.onDoNotAsk();
+    }
+  }
+
+  handleSecondary = () => {
+    this.props.onClose();
+    this.props.onSecondary();
+  }
+
+  handleCancel = () => {
+    this.props.onClose();
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  setDoNotAskRef = (c) => {
+    this.doNotAskCheckbox = c;
+  }
+
+  render () {
+    const { message, confirm, secondary, onDoNotAsk } = this.props;
+
+    return (
+      <div className='modal-root__modal confirmation-modal'>
+        <div className='confirmation-modal__container'>
+          {message}
+        </div>
+
+        <div>
+          { onDoNotAsk && (
+            <div className='confirmation-modal__do_not_ask_again'>
+              <input type='checkbox' id='confirmation-modal__do_not_ask_again-checkbox' ref={this.setDoNotAskRef} />
+              <label for='confirmation-modal__do_not_ask_again-checkbox'>
+                <FormattedMessage id='confirmation_modal.do_not_ask_again' defaultMessage='Do not ask for confirmation again' />
+              </label>
+            </div>
+          )}
+          <div className='confirmation-modal__action-bar'>
+            <Button onClick={this.handleCancel} className='confirmation-modal__cancel-button'>
+              <FormattedMessage id='confirmation_modal.cancel' defaultMessage='Cancel' />
+            </Button>
+            {secondary !== undefined && (
+              <Button text={secondary} onClick={this.handleSecondary} className='confirmation-modal__secondary-button' />
+            )}
+            <Button text={confirm} onClick={this.handleClick} ref={this.setRef} />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js
new file mode 100644
index 000000000..0d10204fc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/doodle_modal.js
@@ -0,0 +1,614 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Button from 'flavours/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 'flavours/glitch/actions/compose';
+import IconButton from 'flavours/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 });
+}
+/** Doodle canvas size options */
+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
+ */
+export default @connect(mapStateToProps, mapDispatchToProps)
+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 canvas size? This will erase your current drawing!')) {
+      return;
+    }
+
+    this.size = newSize;
+  };
+
+  handleClearBtn = () => {
+    if (this.undos.length > 1 && !confirm('Clear canvas? This will erase your current 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/flavours/glitch/features/ui/components/drawer_loading.js b/app/javascript/flavours/glitch/features/ui/components/drawer_loading.js
new file mode 100644
index 000000000..08b0d2347
--- /dev/null
+++ b/app/javascript/flavours/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/flavours/glitch/features/ui/components/embed_modal.js b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
new file mode 100644
index 000000000..b6f5e628d
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/embed_modal.js
@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import api from 'flavours/glitch/util/api';
+import IconButton from 'flavours/glitch/components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+});
+
+export default @injectIntl
+class EmbedModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    url: PropTypes.string.isRequired,
+    onClose: PropTypes.func.isRequired,
+    onError: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  }
+
+  state = {
+    loading: false,
+    oembed: null,
+  };
+
+  componentDidMount () {
+    const { url } = this.props;
+
+    this.setState({ loading: true });
+
+    api().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;
+    }).catch(error => {
+      this.props.onError(error);
+    });
+  }
+
+  setIframeRef = c =>  {
+    this.iframe = c;
+  }
+
+  handleTextareaClick = (e) => {
+    e.target.select();
+  }
+
+  render () {
+    const { intl, onClose } = this.props;
+    const { oembed } = this.state;
+
+    return (
+      <div className='modal-root__modal report-modal embed-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='status.embed' defaultMessage='Embed' />
+        </div>
+
+        <div className='report-modal__container embed-modal__container' style={{ display: 'block' }}>
+          <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}
+            sandbox='allow-same-origin'
+            title='preview'
+          />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
new file mode 100644
index 000000000..176e7c487
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/favourite_modal.js
@@ -0,0 +1,87 @@
+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 'flavours/glitch/components/button';
+import StatusContent from 'flavours/glitch/components/status_content';
+import Avatar from 'flavours/glitch/components/avatar';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+import DisplayName from 'flavours/glitch/components/display_name';
+import Icon from 'flavours/glitch/components/icon';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+
+const messages = defineMessages({
+  favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
+});
+
+export default @injectIntl
+class FavouriteModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    status: ImmutablePropTypes.map.isRequired,
+    onFavourite: PropTypes.func.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  componentDidMount() {
+    this.button.focus();
+  }
+
+  handleFavourite = () => {
+    this.props.onFavourite(this.props.status);
+    this.props.onClose();
+  }
+
+  handleAccountClick = (e) => {
+    if (e.button === 0) {
+      e.preventDefault();
+      this.props.onClose();
+      let state = {...this.context.router.history.location.state};
+      state.mastodonBackSteps = (state.mastodonBackSteps || 0) + 1;
+      this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`, state);
+    }
+  }
+
+  setRef = (c) => {
+    this.button = c;
+  }
+
+  render () {
+    const { status, intl } = this.props;
+
+    return (
+      <div className='modal-root__modal favourite-modal'>
+        <div className='favourite-modal__container'>
+          <div className='status light'>
+            <div className='favourite-modal__status-header'>
+              <div className='favourite-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='favourite-modal__action-bar'>
+          <div><FormattedMessage id='favourite_modal.combo' defaultMessage='You can press {combo} to skip this next time' values={{ combo: <span>Shift + <Icon id='star' /></span> }} /></div>
+          <Button text={intl.formatMessage(messages.favourite)} onClick={this.handleFavourite} ref={this.setRef} />
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
new file mode 100644
index 000000000..77e4bbfa5
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
@@ -0,0 +1,338 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { connect } from 'react-redux';
+import classNames from 'classnames';
+import { changeUploadCompose } from 'flavours/glitch/actions/compose';
+import { getPointerPosition } from 'flavours/glitch/features/video';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import Button from 'flavours/glitch/components/button';
+import Video from 'flavours/glitch/features/video';
+import Audio from 'flavours/glitch/features/audio';
+import Textarea from 'react-textarea-autosize';
+import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
+import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
+import { length } from 'stringz';
+import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components';
+import GIFV from 'flavours/glitch/components/gifv';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  apply: { id: 'upload_modal.apply', defaultMessage: 'Apply' },
+  placeholder: { id: 'upload_modal.description_placeholder', defaultMessage: 'A quick brown fox jumps over the lazy dog' },
+});
+
+const mapStateToProps = (state, { id }) => ({
+  media: state.getIn(['compose', 'media_attachments']).find(item => item.get('id') === id),
+});
+
+const mapDispatchToProps = (dispatch, { id }) => ({
+
+  onSave: (description, x, y) => {
+    dispatch(changeUploadCompose(id, { description, focus: `${x.toFixed(2)},${y.toFixed(2)}` }));
+  },
+
+});
+
+const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******')
+  .replace(/\n/g, ' ')
+  .replace(/\*\*\*\*\*\*/g, '\n\n');
+
+const assetHost = process.env.CDN_HOST || '';
+
+class ImageLoader extends React.PureComponent {
+
+  static propTypes = {
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+  };
+
+  state = {
+    loading: true,
+  };
+
+  componentDidMount() {
+    const image = new Image();
+    image.addEventListener('load', () => this.setState({ loading: false }));
+    image.src = this.props.src;
+  }
+
+  render () {
+    const { loading } = this.state;
+
+    if (loading) {
+      return <canvas width={this.props.width} height={this.props.height} />;
+    } else {
+      return <img {...this.props} alt='' />;
+    }
+  }
+
+}
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class FocalPointModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    x: 0,
+    y: 0,
+    focusX: 0,
+    focusY: 0,
+    dragging: false,
+    description: '',
+    dirty: false,
+    progress: 0,
+    loading: true,
+  };
+
+  componentWillMount () {
+    this.updatePositionFromMedia(this.props.media);
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.media.get('id') !== nextProps.media.get('id')) {
+      this.updatePositionFromMedia(nextProps.media);
+    }
+  }
+
+  componentWillUnmount () {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+  }
+
+  handleMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseMove);
+    document.addEventListener('mouseup', this.handleMouseUp);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
+  handleTouchStart = e => {
+    document.addEventListener('touchmove', this.handleMouseMove);
+    document.addEventListener('touchend', this.handleTouchEnd);
+
+    this.updatePosition(e);
+    this.setState({ dragging: true });
+  }
+
+  handleMouseMove = e => {
+    this.updatePosition(e);
+  }
+
+  handleMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseMove);
+    document.removeEventListener('mouseup', this.handleMouseUp);
+
+    this.setState({ dragging: false });
+  }
+
+  handleTouchEnd = () => {
+    document.removeEventListener('touchmove', this.handleMouseMove);
+    document.removeEventListener('touchend', this.handleTouchEnd);
+
+    this.setState({ dragging: false });
+  }
+
+  updatePosition = e => {
+    const { x, y } = getPointerPosition(this.node, e);
+    const focusX   = (x - .5) *  2;
+    const focusY   = (y - .5) * -2;
+
+    this.setState({ x, y, focusX, focusY, dirty: true });
+  }
+
+  updatePositionFromMedia = media => {
+    const focusX      = media.getIn(['meta', 'focus', 'x']);
+    const focusY      = media.getIn(['meta', 'focus', 'y']);
+    const description = media.get('description') || '';
+
+    if (focusX && focusY) {
+      const x = (focusX /  2) + .5;
+      const y = (focusY / -2) + .5;
+
+      this.setState({
+        x,
+        y,
+        focusX,
+        focusY,
+        description,
+        dirty: false,
+      });
+    } else {
+      this.setState({
+        x: 0.5,
+        y: 0.5,
+        focusX: 0,
+        focusY: 0,
+        description,
+        dirty: false,
+      });
+    }
+  }
+
+  handleChange = e => {
+    this.setState({ description: e.target.value, dirty: true });
+  }
+
+  handleKeyDown = (e) => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.setState({ description: e.target.value, dirty: true });
+      this.handleSubmit();
+    }
+  }
+
+  handleSubmit = () => {
+    this.props.onSave(this.state.description, this.state.focusX, this.state.focusY);
+    this.props.onClose();
+  }
+
+  setRef = c => {
+    this.node = c;
+  }
+
+  handleTextDetection = () => {
+    const { media } = this.props;
+
+    this.setState({ detecting: true });
+
+    fetchTesseract().then(({ TesseractWorker }) => {
+      const worker = new TesseractWorker({
+        workerPath: `${assetHost}/packs/ocr/worker.min.js`,
+        corePath: `${assetHost}/packs/ocr/tesseract-core.wasm.js`,
+        langPath: `${assetHost}/ocr/lang-data`,
+      });
+
+      let media_url = media.get('url');
+
+      if (window.URL && URL.createObjectURL) {
+        try {
+          media_url = URL.createObjectURL(media.get('file'));
+        } catch (error) {
+          console.error(error);
+        }
+      }
+
+      worker.recognize(media_url)
+        .progress(({ progress }) => this.setState({ progress }))
+        .finally(() => worker.terminate())
+        .then(({ text }) => this.setState({ description: removeExtraLineBreaks(text), dirty: true, detecting: false }))
+        .catch(() => this.setState({ detecting: false }));
+    }).catch(() => this.setState({ detecting: false }));
+  }
+
+  render () {
+    const { media, intl, onClose } = this.props;
+    const { x, y, dragging, description, dirty, detecting, progress } = this.state;
+
+    const width  = media.getIn(['meta', 'original', 'width']) || null;
+    const height = media.getIn(['meta', 'original', 'height']) || null;
+    const focals = ['image', 'gifv'].includes(media.get('type'));
+
+    const previewRatio  = 16/9;
+    const previewWidth  = 200;
+    const previewHeight = previewWidth / previewRatio;
+
+    let descriptionLabel = null;
+
+    if (media.get('type') === 'audio') {
+      descriptionLabel = <FormattedMessage id='upload_form.audio_description' defaultMessage='Describe for people with hearing loss' />;
+    } else if (media.get('type') === 'video') {
+      descriptionLabel = <FormattedMessage id='upload_form.video_description' defaultMessage='Describe for people with hearing loss or visual impairment' />;
+    } else {
+      descriptionLabel = <FormattedMessage id='upload_form.description' defaultMessage='Describe for the visually impaired' />;
+    }
+
+    return (
+      <div className='modal-root__modal report-modal' style={{ maxWidth: 960 }}>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='upload_modal.edit_media' defaultMessage='Edit media' />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__comment'>
+            {focals && <p><FormattedMessage id='upload_modal.hint' defaultMessage='Click or drag the circle on the preview to choose the focal point which will always be in view on all thumbnails.' /></p>}
+
+            <label className='setting-text-label' htmlFor='upload-modal__description'>
+              {descriptionLabel}
+            </label>
+
+            <div className='setting-text__wrapper'>
+              <Textarea
+                id='upload-modal__description'
+                className='setting-text light'
+                value={detecting ? '…' : description}
+                onChange={this.handleChange}
+                onKeyDown={this.handleKeyDown}
+                disabled={detecting}
+                autoFocus
+              />
+
+              <div className='setting-text__modifiers'>
+                <UploadProgress progress={progress * 100} active={detecting} icon='file-text-o' message={<FormattedMessage id='upload_modal.analyzing_picture' defaultMessage='Analyzing picture…' />} />
+              </div>
+            </div>
+
+            <div className='setting-text__toolbar'>
+              <button disabled={detecting || media.get('type') !== 'image'} className='link-button' onClick={this.handleTextDetection}><FormattedMessage id='upload_modal.detect_text' defaultMessage='Detect text from picture' /></button>
+              <CharacterCounter max={1500} text={detecting ? '' : description} />
+            </div>
+
+            <Button disabled={!dirty || detecting || length(description) > 1500} text={intl.formatMessage(messages.apply)} onClick={this.handleSubmit} />
+          </div>
+
+          <div className='focal-point-modal__content'>
+            {focals && (
+              <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
+                {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
+                {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
+
+                <div className='focal-point__preview'>
+                  <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
+                  <div style={{ width: previewWidth, height: previewHeight, backgroundImage: `url(${media.get('preview_url')})`, backgroundSize: 'cover', backgroundPosition: `${x * 100}% ${y * 100}%` }} />
+                </div>
+
+                <div className='focal-point__reticle' style={{ top: `${y * 100}%`, left: `${x * 100}%` }} />
+                <div className='focal-point__overlay' />
+              </div>
+            )}
+
+            {media.get('type') === 'video' && (
+              <Video
+                preview={media.get('preview_url')}
+                blurhash={media.get('blurhash')}
+                src={media.get('url')}
+                detailed
+                inline
+                editable
+              />
+            )}
+
+            {media.get('type') === 'audio' && (
+              <Audio
+                src={media.get('url')}
+                duration={media.getIn(['meta', 'original', 'duration'], 0)}
+                height={150}
+                preload
+                editable
+              />
+            )}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js
new file mode 100644
index 000000000..c30427896
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/follow_requests_nav_link.js
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
+import { connect } from 'react-redux';
+import { NavLink, withRouter } from 'react-router-dom';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+import { List as ImmutableList } from 'immutable';
+import { FormattedMessage } from 'react-intl';
+
+const mapStateToProps = state => ({
+  count: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
+});
+
+export default @withRouter
+@connect(mapStateToProps)
+class FollowRequestsNavLink extends React.Component {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    count: PropTypes.number.isRequired,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+
+    dispatch(fetchFollowRequests());
+  }
+
+  render () {
+    const { count } = this.props;
+
+    if (count === 0) {
+      return null;
+    }
+
+    return <NavLink className='column-link column-link--transparent' to='/follow_requests'><IconWithBadge className='column-link__icon' id='user-plus' count={count} /><FormattedMessage id='navigation_bar.follow_requests' defaultMessage='Follow requests' /></NavLink>;
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
new file mode 100644
index 000000000..5e1cf75af
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
@@ -0,0 +1,160 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+import { LoadingBar } from 'react-redux-loading-bar';
+import ZoomableImage from './zoomable_image';
+
+export default class ImageLoader extends React.PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    previewSrc: PropTypes.string,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    onClick: PropTypes.func,
+  }
+
+  static defaultProps = {
+    alt: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    loading: true,
+    error: false,
+    width: null,
+  }
+
+  removers = [];
+  canvas = null;
+
+  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);
+    }
+  }
+
+  componentWillUnmount () {
+    this.removeEventListeners();
+  }
+
+  loadImage (props) {
+    this.removeEventListeners();
+    this.setState({ loading: true, error: false });
+    Promise.all([
+      props.previewSrc && 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;
+    if (c) this.setState({ width: c.offsetWidth });
+  }
+
+  render () {
+    const { alt, src, width, height, onClick } = this.props;
+    const { loading } = this.state;
+
+    const className = classNames('image-loader', {
+      'image-loader--loading': loading,
+      'image-loader--amorphous': !this.hasSize(),
+    });
+
+    return (
+      <div className={className}>
+        <LoadingBar loading={loading ? 1 : 0} className='loading-bar' style={{ width: this.state.width || width }} />
+        {loading ? (
+          <canvas
+            className='image-loader__preview-canvas'
+            ref={this.setCanvasRef}
+            width={width}
+            height={height}
+          />
+        ) : (
+          <ZoomableImage
+            alt={alt}
+            src={src}
+            onClick={onClick}
+          />
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
new file mode 100644
index 000000000..4d7fc36c2
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -0,0 +1,71 @@
+import { connect } from 'react-redux';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import { Link } from 'react-router-dom';
+import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
+import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
+import { logOut } from 'flavours/glitch/util/log_out';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const messages = defineMessages({
+  logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
+  logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
+});
+
+const mapDispatchToProps = (dispatch, { intl }) => ({
+  onLogout () {
+    dispatch(openModal('CONFIRM', {
+      message: intl.formatMessage(messages.logoutMessage),
+      confirm: intl.formatMessage(messages.logoutConfirm),
+      onConfirm: () => logOut(),
+    }));
+  },
+});
+
+export default @injectIntl
+@connect(null, mapDispatchToProps)
+class LinkFooter extends React.PureComponent {
+
+  static propTypes = {
+    onLogout: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleLogoutClick = e => {
+    e.preventDefault();
+    e.stopPropagation();
+
+    this.props.onLogout();
+ 
+    return false;
+  }
+
+  render () {
+    return (
+      <div className='getting-started__footer'>
+        <ul>
+          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
+          <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
+          <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
+          <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
+          <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
+          <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
+          <li><a href={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
+        </ul>
+
+        <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: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener noreferrer' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
+              Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener noreferrer' target='_blank'>Mastodon</a> }}
+          />
+        </p>
+      </div>
+    );
+  }
+
+};
diff --git a/app/javascript/flavours/glitch/features/ui/components/list_panel.js b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
new file mode 100644
index 000000000..354e35027
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/list_panel.js
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { fetchLists } from 'flavours/glitch/actions/lists';
+import { connect } from 'react-redux';
+import { createSelector } from 'reselect';
+import { NavLink, withRouter } from 'react-router-dom';
+import Icon from 'flavours/glitch/components/icon';
+
+const getOrderedLists = createSelector([state => state.get('lists')], lists => {
+  if (!lists) {
+    return lists;
+  }
+
+  return lists.toList().filter(item => !!item).sort((a, b) => a.get('title').localeCompare(b.get('title'))).take(4);
+});
+
+const mapStateToProps = state => ({
+  lists: getOrderedLists(state),
+});
+
+export default @withRouter
+@connect(mapStateToProps)
+class ListPanel extends ImmutablePureComponent {
+
+  static propTypes = {
+    dispatch: PropTypes.func.isRequired,
+    lists: ImmutablePropTypes.list,
+  };
+
+  componentDidMount () {
+    const { dispatch } = this.props;
+    dispatch(fetchLists());
+  }
+
+  render () {
+    const { lists } = this.props;
+
+    if (!lists || lists.isEmpty()) {
+      return null;
+    }
+
+    return (
+      <div>
+        <hr />
+
+        {lists.map(list => (
+          <NavLink key={list.get('id')} className='column-link column-link--transparent' strict to={`/timelines/list/${list.get('id')}`}><Icon className='column-link__icon' id='list-ul' fixedWidth />{list.get('title')}</NavLink>
+        ))}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/media_modal.js b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
new file mode 100644
index 000000000..23e8dac7e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/media_modal.js
@@ -0,0 +1,220 @@
+import React from 'react';
+import ReactSwipeableViews from 'react-swipeable-views';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import classNames from 'classnames';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import IconButton from 'flavours/glitch/components/icon_button';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import ImageLoader from './image_loader';
+import Icon from 'flavours/glitch/components/icon';
+import GIFV from 'flavours/glitch/components/gifv';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
+  next: { id: 'lightbox.next', defaultMessage: 'Next' },
+});
+
+export default @injectIntl
+class MediaModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.list.isRequired,
+    status: ImmutablePropTypes.map,
+    index: PropTypes.number.isRequired,
+    onClose: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  state = {
+    index: null,
+    navigationHidden: false,
+  };
+
+  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 });
+  }
+
+  handleKeyDown = (e) => {
+    switch(e.key) {
+    case 'ArrowLeft':
+      this.handlePrevClick();
+      e.preventDefault();
+      e.stopPropagation();
+      break;
+    case 'ArrowRight':
+      this.handleNextClick();
+      e.preventDefault();
+      e.stopPropagation();
+      break;
+    }
+  }
+
+  componentDidMount () {
+    window.addEventListener('keydown', this.handleKeyDown, false);
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('keydown', this.handleKeyDown);
+  }
+
+  getIndex () {
+    return this.state.index !== null ? this.state.index : this.props.index;
+  }
+
+  toggleNavigation = () => {
+    this.setState(prevState => ({
+      navigationHidden: !prevState.navigationHidden,
+    }));
+  };
+
+  handleStatusClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    }
+  }
+
+  render () {
+    const { media, status, intl, onClose } = this.props;
+    const { navigationHidden } = this.state;
+
+    const index = this.getIndex();
+    let pagination = [];
+
+    const leftNav  = media.size > 1 && <button tabIndex='0' className='media-modal__nav media-modal__nav--left' onClick={this.handlePrevClick} aria-label={intl.formatMessage(messages.previous)}><Icon id='chevron-left' fixedWidth /></button>;
+    const rightNav = media.size > 1 && <button tabIndex='0' className='media-modal__nav  media-modal__nav--right' onClick={this.handleNextClick} aria-label={intl.formatMessage(messages.next)}><Icon id='chevron-right' fixedWidth /></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('url')}
+            onClick={this.toggleNavigation}
+          />
+        );
+      } else if (image.get('type') === 'video') {
+        const { time } = this.props;
+
+        return (
+          <Video
+            preview={image.get('preview_url')}
+            blurhash={image.get('blurhash')}
+            src={image.get('url')}
+            width={image.get('width')}
+            height={image.get('height')}
+            startTime={time || 0}
+            onCloseVideo={onClose}
+            detailed
+            alt={image.get('description')}
+            key={image.get('url')}
+          />
+        );
+      } else if (image.get('type') === 'gifv') {
+        return (
+          <GIFV
+            src={image.get('url')}
+            width={width}
+            height={height}
+            key={image.get('preview_url')}
+            alt={image.get('description')}
+            onClick={this.toggleNavigation}
+          />
+        );
+      }
+
+      return null;
+    }).toArray();
+
+    // you can't use 100vh, because the viewport height is taller
+    // than the visible part of the document in some mobile
+    // browsers when it's address bar is visible.
+    // https://developers.google.com/web/updates/2016/12/url-bar-resizing
+    const swipeableViewsStyle = {
+      width: '100%',
+      height: '100%',
+    };
+
+    const containerStyle = {
+      alignItems: 'center', // center vertically
+    };
+
+    const navigationClassName = classNames('media-modal__navigation', {
+      'media-modal__navigation--hidden': navigationHidden,
+    });
+
+    return (
+      <div className='modal-root__modal media-modal'>
+        <div
+          className='media-modal__closer'
+          role='presentation'
+          onClick={onClose}
+        >
+          <ReactSwipeableViews
+            style={swipeableViewsStyle}
+            containerStyle={containerStyle}
+            onChangeIndex={this.handleSwipe}
+            index={index}
+          >
+            {content}
+          </ReactSwipeableViews>
+        </div>
+
+        <div className={navigationClassName}>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
+
+          {leftNav}
+          {rightNav}
+
+          {status && (
+            <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
+              <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
+            </div>
+          )}
+
+          <ul className='media-modal__pagination'>
+            {pagination}
+          </ul>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_loading.js b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js
new file mode 100644
index 000000000..b1c322154
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_loading.js
@@ -0,0 +1,20 @@
+import React from 'react';
+
+import LoadingIndicator from 'flavours/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/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
new file mode 100644
index 000000000..488daf0cc
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js
@@ -0,0 +1,97 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { getScrollbarWidth } from 'flavours/glitch/util/scrollbar';
+import Base from 'flavours/glitch/components/modal_root';
+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 FavouriteModal from './favourite_modal';
+import AudioModal from './audio_modal';
+import DoodleModal from './doodle_modal';
+import ConfirmationModal from './confirmation_modal';
+import FocalPointModal from './focal_point_modal';
+import {
+  OnboardingModal,
+  MuteModal,
+  BlockModal,
+  ReportModal,
+  SettingsModal,
+  EmbedModal,
+  ListEditor,
+  ListAdder,
+  PinnedAccountsEditor,
+} from 'flavours/glitch/util/async-components';
+
+const MODAL_COMPONENTS = {
+  'MEDIA': () => Promise.resolve({ default: MediaModal }),
+  'ONBOARDING': OnboardingModal,
+  'VIDEO': () => Promise.resolve({ default: VideoModal }),
+  'AUDIO': () => Promise.resolve({ default: AudioModal }),
+  'BOOST': () => Promise.resolve({ default: BoostModal }),
+  'FAVOURITE': () => Promise.resolve({ default: FavouriteModal }),
+  'DOODLE': () => Promise.resolve({ default: DoodleModal }),
+  'CONFIRM': () => Promise.resolve({ default: ConfirmationModal }),
+  'MUTE': MuteModal,
+  'BLOCK': BlockModal,
+  'REPORT': ReportModal,
+  'SETTINGS': SettingsModal,
+  'ACTIONS': () => Promise.resolve({ default: ActionsModal }),
+  'EMBED': EmbedModal,
+  'LIST_EDITOR': ListEditor,
+  'LIST_ADDER':ListAdder,
+  'FOCAL_POINT': () => Promise.resolve({ default: FocalPointModal }),
+  'PINNED_ACCOUNTS_EDITOR': PinnedAccountsEditor,
+};
+
+export default class ModalRoot extends React.PureComponent {
+
+  static propTypes = {
+    type: PropTypes.string,
+    props: PropTypes.object,
+    onClose: PropTypes.func.isRequired,
+  };
+
+  getSnapshotBeforeUpdate () {
+    return { visible: !!this.props.type };
+  }
+
+  componentDidUpdate (prevProps, prevState, { visible }) {
+    if (visible) {
+      document.body.classList.add('with-modals--active');
+      document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
+    } else {
+      document.body.classList.remove('with-modals--active');
+      document.documentElement.style.marginRight = 0;
+    }
+  }
+
+  renderLoading = modalId => () => {
+    return ['MEDIA', 'VIDEO', 'BOOST', 'FAVOURITE', '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 visible = !!type;
+
+    return (
+      <Base onClose={onClose} noEsc={props ? props.noEsc : false}>
+        {visible && (
+          <BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
+            {(SpecificComponent) => <SpecificComponent {...props} onClose={onClose} />}
+          </BundleContainer>
+        )}
+      </Base>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/mute_modal.js b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
new file mode 100644
index 000000000..2aab82751
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/mute_modal.js
@@ -0,0 +1,108 @@
+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 'flavours/glitch/components/button';
+import { closeModal } from 'flavours/glitch/actions/modal';
+import { muteAccount } from 'flavours/glitch/actions/accounts';
+import { toggleHideNotifications } from 'flavours/glitch/actions/mutes';
+
+
+const mapStateToProps = state => {
+  return {
+    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());
+    },
+  };
+};
+
+export default @connect(mapStateToProps, mapDispatchToProps)
+@injectIntl
+class MuteModal extends React.PureComponent {
+
+  static propTypes = {
+    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>
+          <p className='mute-modal__explanation'>
+            <FormattedMessage
+              id='confirmations.mute.explanation'
+              defaultMessage='This will hide posts from them and posts mentioning them, but it will still allow them to see your posts and follow you.'
+            />
+          </p>
+          <div className='setting-toggle'>
+            <Toggle id='mute-modal__hide-notifications-checkbox' checked={notifications} onChange={this.toggleNotifications} />
+            <label className='setting-toggle__label' htmlFor='mute-modal__hide-notifications-checkbox'>
+              <FormattedMessage id='mute_modal.hide_notifications' defaultMessage='Hide notifications from this user?' />
+            </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/flavours/glitch/features/ui/components/navigation_panel.js b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
new file mode 100644
index 000000000..50e7d5c48
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/navigation_panel.js
@@ -0,0 +1,37 @@
+import React from 'react';
+import { NavLink, withRouter } from 'react-router-dom';
+import { FormattedMessage } from 'react-intl';
+import Icon from 'flavours/glitch/components/icon';
+import { profile_directory, showTrends } from 'flavours/glitch/util/initial_state';
+import { preferencesLink, relationshipsLink } from 'flavours/glitch/util/backend_links';
+import NotificationsCounterIcon from './notifications_counter_icon';
+import FollowRequestsNavLink from './follow_requests_nav_link';
+import ListPanel from './list_panel';
+import TrendsContainer from 'flavours/glitch/features/getting_started/containers/trends_container';
+
+const NavigationPanel = ({ onOpenSettings }) => (
+  <div className='navigation-panel'>
+    <NavLink className='column-link column-link--transparent' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
+    <FollowRequestsNavLink />
+    <NavLink className='column-link column-link--transparent' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
+    <NavLink className='column-link column-link--transparent' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/timelines/direct'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
+    <NavLink className='column-link column-link--transparent' to='/bookmarks'><Icon className='column-link__icon' id='bookmark' fixedWidth /><FormattedMessage id='navigation_bar.bookmarks' defaultMessage='Bookmarks' /></NavLink>
+    {profile_directory && <NavLink className='column-link column-link--transparent' to='/directory'><Icon className='column-link__icon' id='address-book-o' fixedWidth /><FormattedMessage id='getting_started.directory' defaultMessage='Profile directory' /></NavLink>}
+    <NavLink className='column-link column-link--transparent' to='/lists'><Icon className='column-link__icon' id='list-ul' fixedWidth /><FormattedMessage id='navigation_bar.lists' defaultMessage='Lists' /></NavLink>
+
+    <ListPanel />
+
+    <hr />
+
+    {!!preferencesLink && <a className='column-link column-link--transparent' href={preferencesLink} target='_blank'><Icon className='column-link__icon' id='cog' fixedWidth /><FormattedMessage id='navigation_bar.preferences' defaultMessage='Preferences' /></a>}
+    <a className='column-link column-link--transparent' href='#' onClick={onOpenSettings}><Icon className='column-link__icon' id='cogs' fixedWidth /><FormattedMessage id='navigation_bar.app_settings' defaultMessage='App settings' /></a>
+    {!!relationshipsLink && <a className='column-link column-link--transparent' href={relationshipsLink} target='_blank'><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='navigation_bar.follows_and_followers' defaultMessage='Follows and followers' /></a>}
+
+    {showTrends && <div className='flex-spacer' />}
+    {showTrends && <TrendsContainer />}
+  </div>
+);
+
+export default withRouter(NavigationPanel);
diff --git a/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
new file mode 100644
index 000000000..6b52ef9b4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/notifications_counter_icon.js
@@ -0,0 +1,9 @@
+import { connect } from 'react-redux';
+import IconWithBadge from 'flavours/glitch/components/icon_with_badge';
+
+const mapStateToProps = state => ({
+  count: state.getIn(['local_settings', 'notifications', 'tab_badge']) ? state.getIn(['notifications', 'unread']) : 0,
+  id: 'bell',
+});
+
+export default connect(mapStateToProps)(IconWithBadge);
diff --git a/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
new file mode 100644
index 000000000..935c26be6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/onboarding_modal.js
@@ -0,0 +1,310 @@
+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 'flavours/glitch/components/permalink';
+import ComposeForm from 'flavours/glitch/features/compose/components/compose_form';
+import DrawerAccount from 'flavours/glitch/features/compose/components/navigation_bar';
+import Search from 'flavours/glitch/features/compose/components/search';
+import ColumnHeader from './column_header';
+import { me } from 'flavours/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 = ({ intl, myAccount }) => (
+  <div className='onboarding-modal__page onboarding-modal__page-two'>
+    <div className='figure non-interactive'>
+      <div className='pseudo-drawer'>
+        <DrawerAccount account={myAccount} />
+        <ComposeForm
+          privacy='public'
+          text='Awoo! #introductions'
+          spoilerText=''
+          suggestions={ [] }
+        />
+      </div>
+    </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 = {
+  intl: PropTypes.object.isRequired,
+  myAccount: ImmutablePropTypes.map.isRequired,
+};
+
+const PageThree = ({ intl, 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'>
+        <DrawerAccount 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 = {
+  intl: PropTypes.object.isRequired,
+  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://joinmastodon.org/apps' 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']),
+});
+
+export default @connect(mapStateToProps)
+@injectIntl
+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} intl={intl} />,
+      <PageThree myAccount={myAccount} intl={intl} />,
+      <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/flavours/glitch/features/ui/components/report_modal.js b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
new file mode 100644
index 000000000..9016b08d7
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/report_modal.js
@@ -0,0 +1,136 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import { changeReportComment, changeReportForward, submitReport } from 'flavours/glitch/actions/reports';
+import { expandAccountTimeline } from 'flavours/glitch/actions/timelines';
+import PropTypes from 'prop-types';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { makeGetAccount } from 'flavours/glitch/selectors';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import StatusCheckBox from 'flavours/glitch/features/report/containers/status_check_box_container';
+import { OrderedSet } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import Button from 'flavours/glitch/components/button';
+import Toggle from 'react-toggle';
+import IconButton from '../../../components/icon_button';
+
+const messages = defineMessages({
+  close: { id: 'lightbox.close', defaultMessage: 'Close' },
+  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']),
+      forward: state.getIn(['reports', 'new', 'forward']),
+      statusIds: OrderedSet(state.getIn(['timelines', `account:${accountId}:with_replies`, 'items'])).union(state.getIn(['reports', 'new', 'status_ids'])),
+    };
+  };
+
+  return mapStateToProps;
+};
+
+export default @connect(makeMapStateToProps)
+@injectIntl
+class ReportModal extends ImmutablePureComponent {
+
+  static propTypes = {
+    isSubmitting: PropTypes.bool,
+    account: ImmutablePropTypes.map,
+    statusIds: ImmutablePropTypes.orderedSet.isRequired,
+    comment: PropTypes.string.isRequired,
+    forward: PropTypes.bool,
+    dispatch: PropTypes.func.isRequired,
+    intl: PropTypes.object.isRequired,
+  };
+
+  handleCommentChange = e => {
+    this.props.dispatch(changeReportComment(e.target.value));
+  }
+
+  handleForwardChange = e => {
+    this.props.dispatch(changeReportForward(e.target.checked));
+  }
+
+  handleSubmit = () => {
+    this.props.dispatch(submitReport());
+  }
+
+  handleKeyDown = e => {
+    if (e.keyCode === 13 && (e.ctrlKey || e.metaKey)) {
+      this.handleSubmit();
+    }
+  }
+
+  componentDidMount () {
+    this.props.dispatch(expandAccountTimeline(this.props.account.get('id'), { withReplies: true }));
+  }
+
+  componentWillReceiveProps (nextProps) {
+    if (this.props.account !== nextProps.account && nextProps.account) {
+      this.props.dispatch(expandAccountTimeline(nextProps.account.get('id'), { withReplies: true }));
+    }
+  }
+
+  render () {
+    const { account, comment, intl, statusIds, isSubmitting, forward, onClose } = this.props;
+
+    if (!account) {
+      return null;
+    }
+
+    const domain = account.get('acct').split('@')[1];
+
+    return (
+      <div className='modal-root__modal report-modal'>
+        <div className='report-modal__target'>
+          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={16} />
+          <FormattedMessage id='report.target' defaultMessage='Report {target}' values={{ target: <strong>{account.get('acct')}</strong> }} />
+        </div>
+
+        <div className='report-modal__container'>
+          <div className='report-modal__comment'>
+            <p><FormattedMessage id='report.hint' defaultMessage='The report will be sent to your server moderators. You can provide an explanation of why you are reporting this account below:' /></p>
+
+            <textarea
+              className='setting-text light'
+              placeholder={intl.formatMessage(messages.placeholder)}
+              value={comment}
+              onChange={this.handleCommentChange}
+              onKeyDown={this.handleKeyDown}
+              disabled={isSubmitting}
+              autoFocus
+            />
+
+            {domain && (
+              <div>
+                <p><FormattedMessage id='report.forward_hint' defaultMessage='The account is from another server. Send an anonymized copy of the report there as well?' /></p>
+
+                <div className='setting-toggle'>
+                  <Toggle id='report-forward' checked={forward} disabled={isSubmitting} onChange={this.handleForwardChange} />
+                  <label htmlFor='report-forward' className='setting-toggle__label'><FormattedMessage id='report.forward' defaultMessage='Forward to {target}' values={{ target: domain }} /></label>
+                </div>
+              </div>
+            )}
+
+            <Button disabled={isSubmitting} text={intl.formatMessage(messages.submit)} onClick={this.handleSubmit} />
+          </div>
+
+          <div className='report-modal__statuses'>
+            <div>
+              {statusIds.map(statusId => <StatusCheckBox id={statusId} key={statusId} disabled={isSubmitting} />)}
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
new file mode 100644
index 000000000..a67405215
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/tabs_bar.js
@@ -0,0 +1,86 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { NavLink, withRouter } from 'react-router-dom';
+import { FormattedMessage, injectIntl } from 'react-intl';
+import { debounce } from 'lodash';
+import { isUserTouching } from 'flavours/glitch/util/is_mobile';
+import Icon from 'flavours/glitch/components/icon';
+import NotificationsCounterIcon from './notifications_counter_icon';
+
+export const links = [
+  <NavLink className='tabs-bar__link' to='/timelines/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
+  <NavLink className='tabs-bar__link' to='/timelines/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
+  <NavLink className='tabs-bar__link' exact to='/timelines/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
+  <NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
+  <NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
+];
+
+export function getIndex (path) {
+  return links.findIndex(link => link.props.to === path);
+}
+
+export function getLink (index) {
+  return links[index].props.to;
+}
+
+export default @injectIntl
+@withRouter
+class TabsBar extends React.PureComponent {
+
+  static propTypes = {
+    intl: PropTypes.object.isRequired,
+    history: 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.props.history.push(to);
+          }, 50);
+
+          nextTab.addEventListener('transitionend', listener);
+          nextTab.classList.add('active');
+        }
+      });
+    }
+
+  }
+
+  render () {
+    const { intl: { formatMessage } } = this.props;
+
+    return (
+      <div className='tabs-bar__wrapper'>
+        <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>
+
+        <div id='tabs-bar__portal' />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/upload_area.js b/app/javascript/flavours/glitch/features/ui/components/upload_area.js
new file mode 100644
index 000000000..11a10baf1
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/upload_area.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Motion from 'flavours/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/flavours/glitch/features/ui/components/video_modal.js b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
new file mode 100644
index 000000000..afeff90a4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/video_modal.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import Video from 'flavours/glitch/features/video';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import classNames from 'classnames';
+import Icon from 'flavours/glitch/components/icon';
+
+export default class VideoModal extends ImmutablePureComponent {
+
+  static contextTypes = {
+    router: PropTypes.object,
+  };
+
+  static propTypes = {
+    media: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
+    options: PropTypes.shape({
+      startTime: PropTypes.number,
+      autoPlay: PropTypes.bool,
+      defaultVolume: PropTypes.number,
+    }),
+    onClose: PropTypes.func.isRequired,
+  };
+
+  handleStatusClick = e => {
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
+      e.preventDefault();
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
+    }
+  }
+
+  render () {
+    const { media, status, onClose } = this.props;
+    const options = this.props.options || {};
+
+    return (
+      <div className='modal-root__modal video-modal'>
+        <div className='video-modal__container'>
+          <Video
+            preview={media.get('preview_url')}
+            blurhash={media.get('blurhash')}
+            src={media.get('url')}
+            startTime={options.startTime}
+            autoPlay={options.autoPlay}
+            defaultVolume={options.defaultVolume}
+            onCloseVideo={onClose}
+            detailed
+            alt={media.get('description')}
+          />
+        </div>
+
+        {status && (
+          <div className={classNames('media-modal__meta')}>
+            <a href={status.get('url')} onClick={this.handleStatusClick}><Icon id='comments' /> <FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
+          </div>
+        )}
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js
new file mode 100644
index 000000000..3f6562bc9
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/components/zoomable_image.js
@@ -0,0 +1,152 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+const MIN_SCALE = 1;
+const MAX_SCALE = 4;
+
+const getMidpoint = (p1, p2) => ({
+  x: (p1.clientX + p2.clientX) / 2,
+  y: (p1.clientY + p2.clientY) / 2,
+});
+
+const getDistance = (p1, p2) =>
+  Math.sqrt(Math.pow(p1.clientX - p2.clientX, 2) + Math.pow(p1.clientY - p2.clientY, 2));
+
+const clamp = (min, max, value) => Math.min(max, Math.max(min, value));
+
+export default class ZoomableImage extends React.PureComponent {
+
+  static propTypes = {
+    alt: PropTypes.string,
+    src: PropTypes.string.isRequired,
+    width: PropTypes.number,
+    height: PropTypes.number,
+    onClick: PropTypes.func,
+  }
+
+  static defaultProps = {
+    alt: '',
+    width: null,
+    height: null,
+  };
+
+  state = {
+    scale: MIN_SCALE,
+  }
+
+  removers = [];
+  container = null;
+  image = null;
+  lastTouchEndTime = 0;
+  lastDistance = 0;
+
+  componentDidMount () {
+    let handler = this.handleTouchStart;
+    this.container.addEventListener('touchstart', handler);
+    this.removers.push(() => this.container.removeEventListener('touchstart', handler));
+    handler = this.handleTouchMove;
+    // on Chrome 56+, touch event listeners will default to passive
+    // https://www.chromestatus.com/features/5093566007214080
+    this.container.addEventListener('touchmove', handler, { passive: false });
+    this.removers.push(() => this.container.removeEventListener('touchend', handler));
+  }
+
+  componentWillUnmount () {
+    this.removeEventListeners();
+  }
+
+  removeEventListeners () {
+    this.removers.forEach(listeners => listeners());
+    this.removers = [];
+  }
+
+  handleTouchStart = e => {
+    if (e.touches.length !== 2) return;
+
+    this.lastDistance = getDistance(...e.touches);
+  }
+
+  handleTouchMove = e => {
+    const { scrollTop, scrollHeight, clientHeight } = this.container;
+    if (e.touches.length === 1 && scrollTop !== scrollHeight - clientHeight) {
+      // prevent propagating event to MediaModal
+      e.stopPropagation();
+      return;
+    }
+    if (e.touches.length !== 2) return;
+
+    e.preventDefault();
+    e.stopPropagation();
+
+    const distance = getDistance(...e.touches);
+    const midpoint = getMidpoint(...e.touches);
+    const scale = clamp(MIN_SCALE, MAX_SCALE, this.state.scale * distance / this.lastDistance);
+
+    this.zoom(scale, midpoint);
+
+    this.lastMidpoint = midpoint;
+    this.lastDistance = distance;
+  }
+
+  zoom(nextScale, midpoint) {
+    const { scale } = this.state;
+    const { scrollLeft, scrollTop } = this.container;
+
+    // math memo:
+    // x = (scrollLeft + midpoint.x) / scrollWidth
+    // x' = (nextScrollLeft + midpoint.x) / nextScrollWidth
+    // scrollWidth = clientWidth * scale
+    // scrollWidth' = clientWidth * nextScale
+    // Solve x = x' for nextScrollLeft
+    const nextScrollLeft = (scrollLeft + midpoint.x) * nextScale / scale - midpoint.x;
+    const nextScrollTop = (scrollTop + midpoint.y) * nextScale / scale - midpoint.y;
+
+    this.setState({ scale: nextScale }, () => {
+      this.container.scrollLeft = nextScrollLeft;
+      this.container.scrollTop = nextScrollTop;
+    });
+  }
+
+  handleClick = e => {
+    // don't propagate event to MediaModal
+    e.stopPropagation();
+    const handler = this.props.onClick;
+    if (handler) handler();
+  }
+
+  setContainerRef = c => {
+    this.container = c;
+  }
+
+  setImageRef = c => {
+    this.image = c;
+  }
+
+  render () {
+    const { alt, src } = this.props;
+    const { scale } = this.state;
+    const overflow = scale === 1 ? 'hidden' : 'scroll';
+
+    return (
+      <div
+        className='zoomable-image'
+        ref={this.setContainerRef}
+        style={{ overflow }}
+      >
+        <img
+          role='presentation'
+          ref={this.setImageRef}
+          alt={alt}
+          title={alt}
+          src={src}
+          style={{
+            transform: `scale(${scale})`,
+            transformOrigin: '0 0',
+          }}
+          onClick={this.handleClick}
+        />
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js b/app/javascript/flavours/glitch/features/ui/containers/bundle_container.js
new file mode 100644
index 000000000..c9086c9bc
--- /dev/null
+++ b/app/javascript/flavours/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 'flavours/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/flavours/glitch/features/ui/containers/columns_area_container.js b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
new file mode 100644
index 000000000..b69842cd6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/columns_area_container.js
@@ -0,0 +1,18 @@
+import { connect } from 'react-redux';
+import ColumnsArea from '../components/columns_area';
+import { openModal } from 'flavours/glitch/actions/modal';
+
+const mapStateToProps = state => ({
+  columns: state.getIn(['settings', 'columns']),
+  swipeToChangeColumns: state.getIn(['local_settings', 'swipe_to_change_columns']),
+});
+
+const mapDispatchToProps = dispatch => ({
+  openSettings (e) {
+    e.preventDefault();
+    e.stopPropagation();
+    dispatch(openModal('SETTINGS', {}));
+  },
+});
+
+export default connect(mapStateToProps, mapDispatchToProps, null, { forwardRef: true })(ColumnsArea);
diff --git a/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js b/app/javascript/flavours/glitch/features/ui/containers/loading_bar_container.js
new file mode 100644
index 000000000..63e994f92
--- /dev/null
+++ b/app/javascript/flavours/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, ownProps) => ({
+  loading: state.get('loadingBar')[ownProps.scope || 'default'],
+});
+
+export default connect(mapStateToProps)(LoadingBar.WrappedComponent);
diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
new file mode 100644
index 000000000..f074002e4
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import { closeModal } from 'flavours/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/flavours/glitch/features/ui/containers/notifications_container.js b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
new file mode 100644
index 000000000..82278a3be
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
@@ -0,0 +1,29 @@
+import { injectIntl } from 'react-intl';
+import { connect } from 'react-redux';
+import { NotificationStack } from 'react-notification';
+import { dismissAlert } from 'flavours/glitch/actions/alerts';
+import { getAlerts } from 'flavours/glitch/selectors';
+
+const mapStateToProps = (state, { intl }) => {
+  const notifications = getAlerts(state);
+
+  notifications.forEach(notification => ['title', 'message'].forEach(key => {
+    const value = notification[key];
+
+    if (typeof value === 'object') {
+      notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
+    }
+  }));
+
+  return { notifications };
+};
+
+const mapDispatchToProps = (dispatch) => {
+  return {
+    onDismiss: alert => {
+      dispatch(dismissAlert(alert));
+    },
+  };
+};
+
+export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NotificationStack));
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
new file mode 100644
index 000000000..bd2d2eb4e
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -0,0 +1,88 @@
+import { connect } from 'react-redux';
+import StatusList from 'flavours/glitch/components/status_list';
+import { scrollTopTimeline, loadPending } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { createSelector } from 'reselect';
+import { debounce } from 'lodash';
+import { me } from 'flavours/glitch/util/initial_state';
+
+const getRegex = createSelector([
+  (state, { type }) => state.getIn(['settings', type, 'regex', 'body']),
+], (rawRegex) => {
+  let regex = null;
+
+  try {
+    regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
+  } catch (e) {
+    // Bad regex, don't affect filters
+  }
+  return regex;
+});
+
+const makeGetStatusIds = (pending = false) => createSelector([
+  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+  (state, { type }) => state.getIn(['timelines', type, pending ? 'pendingItems' : 'items'], ImmutableList()),
+  (state)           => state.get('statuses'),
+  getRegex,
+], (columnSettings, statusIds, statuses, regex) => {
+  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
+
+  return statusIds.filter(id => {
+    if (id === null) return true;
+
+    const statusForId = statuses.get(id);
+    let showStatus    = true;
+
+    if (statusForId.get('account') === me) return true;
+
+    if (columnSettings.getIn(['shows', 'reblog']) === false) {
+      showStatus = showStatus && statusForId.get('reblog') === null;
+    }
+
+    if (columnSettings.getIn(['shows', 'reply']) === false) {
+      showStatus = showStatus && (statusForId.get('in_reply_to_id') === null || statusForId.get('in_reply_to_account_id') === me);
+    }
+
+    if (columnSettings.getIn(['shows', 'direct']) === false) {
+      showStatus = showStatus && statusForId.get('visibility') !== 'direct';
+    }
+
+    if (showStatus && regex) {
+      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 getPendingStatusIds = makeGetStatusIds(true);
+
+  const mapStateToProps = (state, { timelineId }) => ({
+    statusIds: getStatusIds(state, { type: timelineId }),
+    isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true),
+    isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false),
+    hasMore:   state.getIn(['timelines', timelineId, 'hasMore']),
+    numPending: getPendingStatusIds(state, { type: timelineId }).size,
+  });
+
+  return mapStateToProps;
+};
+
+const mapDispatchToProps = (dispatch, { timelineId }) => ({
+
+  onScrollToTop: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, true));
+  }, 100),
+
+  onScroll: debounce(() => {
+    dispatch(scrollTopTimeline(timelineId, false));
+  }, 100),
+
+  onLoadPending: () => dispatch(loadPending(timelineId)),
+
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList);
diff --git a/app/javascript/flavours/glitch/features/ui/index.js b/app/javascript/flavours/glitch/features/ui/index.js
new file mode 100644
index 000000000..bf76c0e57
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/ui/index.js
@@ -0,0 +1,622 @@
+import React from 'react';
+import NotificationsContainer from './containers/notifications_container';
+import PropTypes from 'prop-types';
+import LoadingBarContainer from './containers/loading_bar_container';
+import ModalContainer from './containers/modal_container';
+import { connect } from 'react-redux';
+import { Redirect, withRouter } from 'react-router-dom';
+import { isMobile } from 'flavours/glitch/util/is_mobile';
+import { debounce } from 'lodash';
+import { uploadCompose, resetCompose, changeComposeSpoilerness } from 'flavours/glitch/actions/compose';
+import { expandHomeTimeline } from 'flavours/glitch/actions/timelines';
+import { expandNotifications, notificationsSetVisibility } from 'flavours/glitch/actions/notifications';
+import { fetchFilters } from 'flavours/glitch/actions/filters';
+import { clearHeight } from 'flavours/glitch/actions/height_cache';
+import { synchronouslySubmitMarkers, fetchMarkers } from 'flavours/glitch/actions/markers';
+import { WrappedSwitch, WrappedRoute } from 'flavours/glitch/util/react_router_helpers';
+import UploadArea from './components/upload_area';
+import PermaLink from 'flavours/glitch/components/permalink';
+import ColumnsAreaContainer from './containers/columns_area_container';
+import classNames from 'classnames';
+import Favico from 'favico.js';
+import {
+  Compose,
+  Status,
+  GettingStarted,
+  KeyboardShortcuts,
+  PublicTimeline,
+  CommunityTimeline,
+  AccountTimeline,
+  AccountGallery,
+  HomeTimeline,
+  Followers,
+  Following,
+  Reblogs,
+  Favourites,
+  DirectTimeline,
+  HashtagTimeline,
+  Notifications,
+  FollowRequests,
+  GenericNotFound,
+  FavouritedStatuses,
+  BookmarkedStatuses,
+  ListTimeline,
+  Blocks,
+  DomainBlocks,
+  Mutes,
+  PinnedStatuses,
+  Lists,
+  Search,
+  GettingStartedMisc,
+  Directory,
+} from 'flavours/glitch/util/async-components';
+import { HotKeys } from 'react-hotkeys';
+import { me } from 'flavours/glitch/util/initial_state';
+import { defineMessages, FormattedMessage, 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 => ({
+  hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
+  hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
+  canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
+  layout: state.getIn(['local_settings', 'layout']),
+  isWide: state.getIn(['local_settings', 'stretch']),
+  navbarUnder: state.getIn(['local_settings', 'navbar_under']),
+  dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
+  unreadNotifications: state.getIn(['notifications', 'unread']),
+  showFaviconBadge: state.getIn(['local_settings', 'notifications', 'favicon_badge']),
+  hicolorPrivacyIcons: state.getIn(['local_settings', 'hicolor_privacy_icons']),
+  moved: state.getIn(['accounts', me, 'moved']) && state.getIn(['accounts', state.getIn(['accounts', me, 'moved'])]),
+});
+
+const keyMap = {
+  help: '?',
+  new: 'n',
+  search: 's',
+  forceNew: 'option+n',
+  toggleComposeSpoilers: 'option+x',
+  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',
+  goToRequests: 'g r',
+  toggleSpoiler: 'x',
+  bookmark: 'd',
+  toggleCollapse: 'shift+x',
+  toggleSensitive: 'h',
+  openMedia: 'e',
+};
+
+class SwitchingColumnsArea extends React.PureComponent {
+
+  static propTypes = {
+    children: PropTypes.node,
+    layout: PropTypes.string,
+    location: PropTypes.object,
+    navbarUnder: PropTypes.bool,
+    onLayoutChange: PropTypes.func.isRequired,
+  };
+
+  state = {
+    mobile: isMobile(window.innerWidth, this.props.layout),
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.layout !== this.props.layout) {
+      this.setState({ mobile: isMobile(window.innerWidth, nextProps.layout) });
+    }
+  }
+
+  componentWillMount () {
+    window.addEventListener('resize', this.handleResize, { passive: true });
+
+    if (this.state.mobile) {
+      document.body.classList.toggle('layout-single-column', true);
+      document.body.classList.toggle('layout-multiple-columns', false);
+    } else {
+      document.body.classList.toggle('layout-single-column', false);
+      document.body.classList.toggle('layout-multiple-columns', true);
+    }
+  }
+
+  componentDidUpdate (prevProps, prevState) {
+    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
+      this.node.handleChildrenContentChange();
+    }
+
+    if (prevState.mobile !== this.state.mobile) {
+      document.body.classList.toggle('layout-single-column', this.state.mobile);
+      document.body.classList.toggle('layout-multiple-columns', !this.state.mobile);
+    }
+  }
+
+  componentWillUnmount () {
+    window.removeEventListener('resize', this.handleResize);
+  }
+
+  handleLayoutChange = debounce(() => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.onLayoutChange();
+  }, 500, {
+    trailing: true,
+  })
+
+  handleResize = () => {
+    const mobile = isMobile(window.innerWidth, this.props.layout);
+
+    if (mobile !== this.state.mobile) {
+      this.handleLayoutChange.cancel();
+      this.props.onLayoutChange();
+      this.setState({ mobile });
+    } else {
+      this.handleLayoutChange();
+    }
+  }
+
+  setRef = c => {
+    if (c) {
+      this.node = c.getWrappedInstance();
+    }
+  }
+
+  render () {
+    const { children, navbarUnder } = this.props;
+    const singleColumn = this.state.mobile;
+    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
+
+    return (
+      <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn} navbarUnder={navbarUnder}>
+        <WrappedSwitch>
+          {redirect}
+          <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
+          <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
+          <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
+          <WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
+          <WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
+          <WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
+          <WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
+          <WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />
+
+          <WrappedRoute path='/notifications' component={Notifications} content={children} />
+          <WrappedRoute path='/favourites' component={FavouritedStatuses} content={children} />
+          <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
+          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
+
+          <WrappedRoute path='/search' component={Search} content={children} />
+          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
+
+          <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/with_replies' component={AccountTimeline} content={children} componentParams={{ withReplies: true }} />
+          <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='/domain_blocks' component={DomainBlocks} content={children} />
+          <WrappedRoute path='/mutes' component={Mutes} content={children} />
+          <WrappedRoute path='/lists' component={Lists} content={children} />
+          <WrappedRoute path='/getting-started-misc' component={GettingStartedMisc} content={children} />
+
+          <WrappedRoute component={GenericNotFound} content={children} />
+        </WrappedSwitch>
+      </ColumnsAreaContainer>
+    );
+  };
+
+}
+
+export default @connect(mapStateToProps)
+@injectIntl
+@withRouter
+class UI extends React.Component {
+
+  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,
+    hasMediaAttachments: PropTypes.bool,
+    canUploadMore: PropTypes.bool,
+    match: PropTypes.object.isRequired,
+    location: PropTypes.object.isRequired,
+    history: PropTypes.object.isRequired,
+    intl: PropTypes.object.isRequired,
+    dropdownMenuIsOpen: PropTypes.bool,
+    unreadNotifications: PropTypes.number,
+    showFaviconBadge: PropTypes.bool,
+    moved: PropTypes.map,
+  };
+
+  state = {
+    draggingOver: false,
+  };
+
+  handleBeforeUnload = (e) => {
+    const { intl, dispatch, hasComposingText, hasMediaAttachments } = this.props;
+
+    dispatch(synchronouslySubmitMarkers());
+
+    if (hasComposingText || hasMediaAttachments) {
+      // 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);
+    }
+  }
+
+  handleLayoutChange = () => {
+    // The cached heights are no longer accurate, invalidate
+    this.props.dispatch(clearHeight());
+  }
+
+  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.props.canUploadMore) {
+      this.setState({ draggingOver: true });
+    }
+  }
+
+  handleDragOver = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return false;
+    e.preventDefault();
+    e.stopPropagation();
+
+    try {
+      e.dataTransfer.dropEffect = 'copy';
+    } catch (err) {
+
+    }
+
+    return false;
+  }
+
+  handleDrop = (e) => {
+    if (this.dataTransferIsText(e.dataTransfer)) return;
+
+    e.preventDefault();
+
+    this.setState({ draggingOver: false });
+    this.dragTargets = [];
+
+    if (e.dataTransfer && e.dataTransfer.files.length >= 1 && this.props.canUploadMore) {
+      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 });
+  }
+
+  dataTransferIsText = (dataTransfer) => {
+    return (dataTransfer && Array.from(dataTransfer.types).filter((type) => type === 'text/plain').length === 1);
+  }
+
+  closeUploadModal = () => {
+    this.setState({ draggingOver: false });
+  }
+
+  handleServiceWorkerPostMessage = ({ data }) => {
+    if (data.type === 'navigate') {
+      this.props.history.push(data.path);
+    } else {
+      console.warn('Unknown message type:', data.type);
+    }
+  }
+
+  handleVisibilityChange = () => {
+    const visibility = !document[this.visibilityHiddenProp];
+    this.props.dispatch(notificationsSetVisibility(visibility));
+  }
+
+  componentWillMount () {
+    if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
+      this.visibilityHiddenProp = 'hidden';
+      this.visibilityChange = 'visibilitychange';
+    } else if (typeof document.msHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'msHidden';
+      this.visibilityChange = 'msvisibilitychange';
+    } else if (typeof document.webkitHidden !== 'undefined') {
+      this.visibilityHiddenProp = 'webkitHidden';
+      this.visibilityChange = 'webkitvisibilitychange';
+    }
+    if (this.visibilityChange !== undefined) {
+      document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
+      this.handleVisibilityChange();
+    }
+
+    window.addEventListener('beforeunload', this.handleBeforeUnload, false);
+    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.favicon = new Favico({ animation:"none" });
+
+    this.props.dispatch(fetchMarkers());
+    this.props.dispatch(expandHomeTimeline());
+    this.props.dispatch(expandNotifications());
+    setTimeout(() => this.props.dispatch(fetchFilters()), 500);
+  }
+
+  componentDidMount () {
+    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
+      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
+    };
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.props.unreadNotifications != prevProps.unreadNotifications ||
+        this.props.showFaviconBadge != prevProps.showFaviconBadge) {
+      if (this.favicon) {
+        try {
+          this.favicon.badge(this.props.showFaviconBadge ? this.props.unreadNotifications : 0);
+        } catch (err) {
+          console.error(err);
+        }
+      }
+    }
+  }
+
+  componentWillUnmount () {
+    if (this.visibilityChange !== undefined) {
+      document.removeEventListener(this.visibilityChange, this.handleVisibilityChange);
+    }
+
+    window.removeEventListener('beforeunload', this.handleBeforeUnload);
+    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;
+  }
+
+  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());
+  }
+
+  handleHotkeyToggleComposeSpoilers = e => {
+    e.preventDefault();
+    this.props.dispatch(changeComposeSpoilerness());
+  }
+
+  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) return;
+    const container = column.querySelector('.scrollable');
+
+    if (container) {
+      const status = container.querySelector('.focusable');
+
+      if (status) {
+        if (container.scrollTop > status.offsetTop) {
+          status.scrollIntoView(true);
+        }
+        status.focus();
+      }
+    }
+  }
+
+  handleHotkeyBack = () => {
+    // if history is exhausted, or we would leave mastodon, just go to root.
+    if (window.history.state) {
+      this.props.history.goBack();
+    } else {
+      this.props.history.push('/');
+    }
+  }
+
+  setHotkeysRef = c => {
+    this.hotkeys = c;
+  }
+
+  handleHotkeyToggleHelp = () => {
+    if (this.props.location.pathname === '/keyboard-shortcuts') {
+      this.props.history.goBack();
+    } else {
+      this.props.history.push('/keyboard-shortcuts');
+    }
+  }
+
+  handleHotkeyGoToHome = () => {
+    this.props.history.push('/timelines/home');
+  }
+
+  handleHotkeyGoToNotifications = () => {
+    this.props.history.push('/notifications');
+  }
+
+  handleHotkeyGoToLocal = () => {
+    this.props.history.push('/timelines/public/local');
+  }
+
+  handleHotkeyGoToFederated = () => {
+    this.props.history.push('/timelines/public');
+  }
+
+  handleHotkeyGoToDirect = () => {
+    this.props.history.push('/timelines/direct');
+  }
+
+  handleHotkeyGoToStart = () => {
+    this.props.history.push('/getting-started');
+  }
+
+  handleHotkeyGoToFavourites = () => {
+    this.props.history.push('/favourites');
+  }
+
+  handleHotkeyGoToPinned = () => {
+    this.props.history.push('/pinned');
+  }
+
+  handleHotkeyGoToProfile = () => {
+    this.props.history.push(`/accounts/${me}`);
+  }
+
+  handleHotkeyGoToBlocked = () => {
+    this.props.history.push('/blocks');
+  }
+
+  handleHotkeyGoToMuted = () => {
+    this.props.history.push('/mutes');
+  }
+
+  handleHotkeyGoToRequests = () => {
+    this.props.history.push('/follow_requests');
+  }
+
+  render () {
+    const { draggingOver } = this.state;
+    const { children, layout, isWide, navbarUnder, location, dropdownMenuIsOpen, moved } = 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,
+      'hicolor-privacy-icons': this.props.hicolorPrivacyIcons,
+    });
+
+    const handlers = {
+      help: this.handleHotkeyToggleHelp,
+      new: this.handleHotkeyNew,
+      search: this.handleHotkeySearch,
+      forceNew: this.handleHotkeyForceNew,
+      toggleComposeSpoilers: this.handleHotkeyToggleComposeSpoilers,
+      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,
+      goToRequests: this.handleHotkeyGoToRequests,
+    };
+
+    return (
+      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
+        <div className={className} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
+          {moved && (<div className='flash-message alert'>
+            <FormattedMessage
+              id='moved_to_warning'
+              defaultMessage='This account is marked as moved to {moved_to_link}, and may thus not accept new follows.'
+              values={{ moved_to_link: (
+                <PermaLink href={moved.get('url')} to={`/accounts/${moved.get('id')}`}>
+                  @{moved.get('acct')}
+                </PermaLink>
+              )}}
+            />
+          </div>)}
+          <SwitchingColumnsArea location={location} layout={layout} navbarUnder={navbarUnder} onLayoutChange={this.handleLayoutChange}>
+            {children}
+          </SwitchingColumnsArea>
+
+          <NotificationsContainer />
+          <LoadingBarContainer className='loading-bar' />
+          <ModalContainer />
+          <UploadArea active={draggingOver} onClose={this.closeUploadModal} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
new file mode 100644
index 000000000..a89d9c8b0
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -0,0 +1,543 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+import { fromJS, is } from 'immutable';
+import { throttle } from 'lodash';
+import classNames from 'classnames';
+import { isFullscreen, requestFullscreen, exitFullscreen } from 'flavours/glitch/util/fullscreen';
+import { displayMedia, useBlurhash } from 'flavours/glitch/util/initial_state';
+import Icon from 'flavours/glitch/components/icon';
+import { decode } from 'blurhash';
+
+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' },
+  download: { id: 'video.download', defaultMessage: 'Download file' },
+});
+
+export const formatTime = secondsNum => {
+  let hours   = Math.floor(secondsNum / 3600);
+  let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
+  let seconds = secondsNum - (hours * 3600) - (minutes * 60);
+
+  if (hours   < 10) hours   = '0' + hours;
+  if (minutes < 10) minutes = '0' + minutes;
+  if (seconds < 10) seconds = '0' + seconds;
+  return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
+};
+
+export 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),
+  };
+};
+
+export 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, (pageY - boxY) / boxH));
+  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
+
+  return position;
+};
+
+export default @injectIntl
+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,
+    detailed: PropTypes.bool,
+    inline: PropTypes.bool,
+    editable: PropTypes.bool,
+    cacheWidth: PropTypes.func,
+    intl: PropTypes.object.isRequired,
+    visible: PropTypes.bool,
+    onToggleVisibility: PropTypes.func,
+    preventPlayback: PropTypes.bool,
+    blurhash: PropTypes.string,
+    link: PropTypes.node,
+    autoPlay: PropTypes.bool,
+    defaultVolume: PropTypes.number,
+  };
+
+  state = {
+    currentTime: 0,
+    duration: 0,
+    volume: 0.5,
+    paused: true,
+    dragging: false,
+    containerWidth: this.props.width,
+    fullscreen: false,
+    hovered: false,
+    muted: false,
+    revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
+  };
+
+  componentWillReceiveProps (nextProps) {
+    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
+      this.setState({ revealed: nextProps.visible });
+    }
+  }
+
+  // hard coded in components.scss
+  // any way to get ::before values programatically?
+  volWidth = 50;
+  volOffset = 70;
+  volHandleOffset = v => {
+    const offset = v * this.volWidth + this.volOffset;
+    return (offset > 110) ? 110 : offset;
+  }
+
+  setPlayerRef = c => {
+    this.player = c;
+
+    if (c && c.offsetWidth && c.offsetWidth != this.state.containerWidth) {
+      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
+      this.setState({
+        containerWidth: c.offsetWidth,
+      });
+    }
+  }
+
+  setVideoRef = c => {
+    this.video = c;
+
+    if (this.video) {
+      this.setState({ volume: this.video.volume, muted: this.video.muted });
+    }
+  }
+
+  setSeekRef = c => {
+    this.seek = c;
+  }
+
+  setVolumeRef = c => {
+    this.volume = c;
+  }
+
+  setCanvasRef = c => {
+    this.canvas = c;
+
+    if (c && this.props.blurhash) {
+      this._decode();
+    }
+  }
+
+  handleMouseDownRoot = e => {
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handlePlay = () => {
+    this.setState({ paused: false });
+  }
+
+  handlePause = () => {
+    this.setState({ paused: true });
+  }
+
+  handleTimeUpdate = () => {
+    this.setState({
+      currentTime: Math.floor(this.video.currentTime),
+      duration: Math.floor(this.video.duration),
+    });
+  }
+
+  handleVolumeMouseDown = e => {
+    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.addEventListener('touchend', this.handleVolumeMouseUp, true);
+
+    this.handleMouseVolSlide(e);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  handleVolumeMouseUp = () => {
+    document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
+    document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
+    document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
+    document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
+  }
+
+  handleMouseVolSlide = throttle(e => {
+    const rect = this.volume.getBoundingClientRect();
+    const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
+
+    if(!isNaN(x)) {
+      var slideamt = x;
+      if(x > 1) {
+        slideamt = 1;
+      } else if(x < 0) {
+        slideamt = 0;
+      }
+      this.video.volume = slideamt;
+      this.setState({ volume: slideamt });
+    }
+  }, 60);
+
+  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);
+
+    e.preventDefault();
+    e.stopPropagation();
+  }
+
+  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);
+    const currentTime = Math.floor(this.video.duration * x);
+
+    if (!isNaN(currentTime)) {
+      this.video.currentTime = currentTime;
+      this.setState({ currentTime });
+    }
+  }, 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);
+
+    if (this.props.blurhash) {
+      this._decode();
+    }
+  }
+
+  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);
+  }
+
+  componentDidUpdate (prevProps) {
+    if (this.player && this.player.offsetWidth && this.player.offsetWidth != this.state.containerWidth && !this.state.fullscreen) {
+      if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
+      this.setState({
+        containerWidth: this.player.offsetWidth,
+      });
+    }
+    if (this.video && this.state.revealed && this.props.preventPlayback && !prevProps.preventPlayback) {
+      this.video.pause();
+    }
+    if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
+      this._decode();
+    }
+  }
+
+  _decode () {
+    if (!this.canvas || !useBlurhash) return;
+
+    const hash   = this.props.blurhash;
+    const pixels = decode(hash, 32, 32);
+
+    if (pixels) {
+      const ctx       = this.canvas.getContext('2d');
+      const imageData = new ImageData(pixels, 32, 32);
+
+      ctx.putImageData(imageData, 0, 0);
+    }
+  }
+
+  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();
+    }
+
+    if (this.props.onToggleVisibility) {
+      this.props.onToggleVisibility();
+    } else {
+      this.setState({ revealed: !this.state.revealed });
+    }
+  }
+
+  handleLoadedData = () => {
+    if (this.props.startTime) {
+      this.video.currentTime = this.props.startTime;
+    }
+
+    if (this.props.defaultVolume !== undefined) {
+      this.video.volume = this.props.defaultVolume;
+    }
+
+    if (this.props.autoPlay) {
+      this.video.play();
+    }
+  }
+
+  handleProgress = () => {
+    if (this.video.buffered.length > 0) {
+      this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
+    }
+  }
+
+  handleVolumeChange = () => {
+    this.setState({ volume: this.video.volume, muted: this.video.muted });
+  }
+
+  handleOpenVideo = () => {
+    const { src, preview, width, height, alt } = this.props;
+
+    const media = fromJS({
+      type: 'video',
+      url: src,
+      preview_url: preview,
+      description: alt,
+      width,
+      height,
+    });
+
+    const options = {
+      startTime: this.video.currentTime,
+      autoPlay: !this.state.paused,
+      defaultVolume: this.state.volume,
+    };
+
+    this.video.pause();
+    this.props.onOpenVideo(media, options);
+  }
+
+  handleCloseVideo = () => {
+    this.video.pause();
+    this.props.onCloseVideo();
+  }
+
+  render () {
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, letterbox, fullwidth, detailed, sensitive, link, editable } = this.props;
+    const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
+    const progress = (currentTime / duration) * 100;
+    const playerStyle = {};
+
+    const volumeWidth = (muted) ? 0 : volume * this.volWidth;
+    const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
+
+    const computedClass = classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable, letterbox, 'full-width': fullwidth });
+
+    let { width, height } = this.props;
+
+    if (inline && containerWidth) {
+      width  = containerWidth;
+      height = containerWidth / (16/9);
+
+      playerStyle.height = height;
+    } else if (inline) {
+      return (<div className={computedClass} ref={this.setPlayerRef} tabindex={0}></div>);
+    }
+
+    let warning;
+    if (sensitive) {
+      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
+    } else {
+      warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
+    }
+
+    let preload;
+
+    if (startTime || fullscreen || dragging) {
+      preload = 'auto';
+    } else if (detailed) {
+      preload = 'metadata';
+    } else {
+      preload = 'none';
+    }
+
+    return (
+      <div
+        className={computedClass}
+        style={playerStyle}
+        ref={this.setPlayerRef}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseLeave={this.handleMouseLeave}
+        onMouseDown={this.handleMouseDownRoot}
+        tabIndex={0}
+      >
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
+
+        {(revealed || editable) && <video
+          ref={this.setVideoRef}
+          src={src}
+          poster={preview}
+          preload={preload}
+          loop
+          role='button'
+          tabIndex='0'
+          aria-label={alt}
+          title={alt}
+          width={width}
+          height={height}
+          volume={volume}
+          onClick={this.togglePlay}
+          onPlay={this.handlePlay}
+          onPause={this.handlePause}
+          onTimeUpdate={this.handleTimeUpdate}
+          onLoadedData={this.handleLoadedData}
+          onProgress={this.handleProgress}
+          onVolumeChange={this.handleVolumeChange}
+        />}
+
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
+            <span className='spoiler-button__overlay__label'>{warning}</span>
+          </button>
+        </div>
+
+        <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-bar'>
+            <div className='video-player__buttons left'>
+              <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
+              <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
+
+              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
+                &nbsp;
+                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
+                <span
+                  className={classNames('video-player__volume__handle')}
+                  tabIndex='0'
+                  style={{ left: `${volumeHandleLoc}px` }}
+                />
+              </div>
+
+              {(detailed || fullscreen) && (
+                <span>
+                  <span className='video-player__time-current'>{formatTime(currentTime)}</span>
+                  <span className='video-player__time-sep'>/</span>
+                  <span className='video-player__time-total'>{formatTime(duration)}</span>
+                </span>
+              )}
+
+              {link && <span className='video-player__link'>{link}</span>}
+            </div>
+
+            <div className='video-player__buttons right'>
+              {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
+              {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
+              {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
+              <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)}><a className='video-player__download__icon' href={this.props.src} download><Icon id={'download'} fixedWidth /></a></button>
+              <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
+            </div>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}